Files
2026-05-10 12:53:30 -07:00

312 lines
9.7 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import datetime
import json
import logging
import os
import subprocess
from datetime import datetime
from pathlib import Path
class Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
BLACK = "\033[30m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
WHITE = "\033[37m"
GREY = "\033[90m"
@staticmethod
def Background(color: str) -> str:
return color.replace("[3", "[4", 1)
class CustomFormatter(logging.Formatter):
time_format = f"{Colors.GREY}%(asctime)s{Colors.RESET}"
FORMATS = {
logging.DEBUG: f"{time_format} {Colors.BOLD}{Colors.CYAN}DEBG{Colors.RESET} %(message)s",
logging.INFO: f"{time_format} {Colors.BOLD}{Colors.GREEN}INFO{Colors.RESET} %(message)s",
logging.WARNING: f"{time_format} {Colors.BOLD}{Colors.YELLOW}WARN{Colors.RESET} %(message)s",
logging.ERROR: f"{time_format} {Colors.BOLD}{Colors.RED}ERRR{Colors.RESET} %(message)s",
logging.CRITICAL: f"{time_format} {Colors.BOLD}{Colors.Background(Colors.RED)}CRIT{Colors.RESET} %(message)s",
}
def format(self, record: any) -> str:
log_fmt = self.FORMATS.get(record.levelno)
formatter = logging.Formatter(log_fmt, datefmt="%H:%M")
return formatter.format(record)
def main() -> None:
parser = argparse.ArgumentParser(
description="reads Arc Browser JSON data, converts it to HTML, and writes the output to a specified file."
)
parser.add_argument("-s", "--silent", action="store_true", help="silence output")
parser.add_argument(
"-o", "--output", type=Path, required=False, help="specify the output file path"
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
default=False,
help="enable verbose output",
)
parser.add_argument(
"--version",
action="store_true",
help="print the git short hash and commit time",
)
args = parser.parse_args()
if args.silent:
logging.disable(logging.CRITICAL)
else:
setup_logging(args.verbose)
if args.version:
commit_hash, commit_time = get_version()
if commit_hash is None or commit_time is None:
logging.critical("Could not fetch Git metadata.")
return
print(
f"{Colors.BOLD}GIT TIME{Colors.RESET} | {Colors.GREEN}{commit_time.strftime('%Y-%m-%d')}{Colors.RESET} [{Colors.YELLOW}{int(commit_time.timestamp())}{Colors.RESET}]"
)
print(
f"{Colors.BOLD}GIT HASH{Colors.RESET} | {Colors.MAGENTA}{commit_hash}{Colors.RESET}"
)
return
data: dict = read_json()
html: str = convert_json_to_html(data)
write_html(html, args.output)
logging.info("Done!")
def setup_logging(is_verbose: bool) -> None:
handler = logging.StreamHandler()
handler.setFormatter(CustomFormatter())
logging.basicConfig(level=logging.DEBUG, handlers=[handler])
if is_verbose:
logging.getLogger().setLevel(logging.DEBUG)
else:
logging.getLogger().setLevel(logging.INFO)
def get_version() -> tuple[str, datetime]:
try:
commit_hash: str = (
subprocess.check_output(["git", "rev-parse", "--short", "HEAD"])
.decode("utf-8")
.strip()
)
commit_time_str: str = (
subprocess.check_output(["git", "log", "-1", "--format=%ct"])
.decode("utf-8")
.strip()
)
commit_time = datetime.fromtimestamp(int(commit_time_str))
except Exception:
commit_hash = None
commit_time = None
return commit_hash, commit_time
def read_json() -> dict:
logging.info("Reading JSON...")
filename: Path = Path("StorableSidebar.json")
if os.name == "nt":
arc_root_parent_path: Path = Path(
os.path.expanduser(r"~\AppData\Local\Packages")
)
arc_root_paths: list[Path] = [
f
for f in arc_root_parent_path.glob("*")
if f.name.startswith("TheBrowserCompany.Arc")
]
if len(arc_root_paths) != 1:
raise FileNotFoundError
library_path: Path = Path(
arc_root_paths[0].joinpath(r"LocalCache\Local\Arc")
).joinpath(filename)
else:
library_path: Path = Path(
os.path.expanduser("~/Library/Application Support/Arc/")
).joinpath(filename)
data: dict = {}
if filename.exists():
with filename.open("r", encoding="utf-8") as f:
logging.debug(f"Found {filename} in current directory.")
data = json.load(f)
elif library_path.exists():
with library_path.open("r", encoding="utf-8") as f:
logging.debug(f"Found {filename} in Library directory.")
data = json.load(f)
else:
logging.critical(
'> File not found. Look for the "StorableSidebar.json" '
' file within the "~/Library/Application Support/Arc/" folder.'
)
raise FileNotFoundError
return data
def convert_json_to_html(json_data: dict) -> str:
containers: list = json_data["sidebar"]["containers"]
try:
target: int = next(i + 1 for i, c in enumerate(containers) if "global" in c)
except StopIteration:
raise ValueError("No container with 'global' found in the sidebar data")
spaces: dict = get_spaces(json_data["sidebar"]["containers"][target]["spaces"])
items: list = json_data["sidebar"]["containers"][target]["items"]
bookmarks: dict = convert_to_bookmarks(spaces, items)
html_content: str = convert_bookmarks_to_html(bookmarks)
return html_content
def get_spaces(spaces: list) -> dict:
logging.info("Getting spaces...")
spaces_names: dict = {"pinned": {}, "unpinned": {}}
spaces_count: int = 0
n: int = 1
for space in spaces:
if "title" in space:
title: str = space["title"]
else:
title: str = "Space " + str(n)
n += 1
# TODO: Find a better way to determine if a space is pinned or not
if isinstance(space, dict):
containers: list = space["newContainerIDs"]
for i in range(len(containers)):
if isinstance(containers[i], dict):
if "pinned" in containers[i]:
spaces_names["pinned"][str(containers[i + 1])]: str = title
elif "unpinned" in containers[i]:
spaces_names["unpinned"][str(containers[i + 1])]: str = title
spaces_count += 1
logging.debug(f"Found {spaces_count} spaces.")
return spaces_names
def convert_to_bookmarks(spaces: dict, items: list) -> dict:
logging.info("Converting to bookmarks...")
bookmarks: dict = {"bookmarks": []}
bookmarks_count: int = 0
item_dict: dict = {item["id"]: item for item in items if isinstance(item, dict)}
def recurse_into_children(parent_id: str) -> list:
nonlocal bookmarks_count
children: list = []
for item_id, item in item_dict.items():
if item.get("parentID") == parent_id:
if "data" in item and "tab" in item["data"]:
children.append(
{
"title": item.get("title", None)
or item["data"]["tab"].get("savedTitle", ""),
"type": "bookmark",
"url": item["data"]["tab"].get("savedURL", ""),
}
)
bookmarks_count += 1
elif "title" in item:
child_folder: dict = {
"title": item["title"],
"type": "folder",
"children": recurse_into_children(item_id),
}
children.append(child_folder)
return children
for space_id, space_name in spaces["pinned"].items():
space_folder: dict = {
"title": space_name,
"type": "folder",
"children": recurse_into_children(space_id),
}
bookmarks["bookmarks"].append(space_folder)
logging.debug(f"Found {bookmarks_count} bookmarks.")
return bookmarks
def convert_bookmarks_to_html(bookmarks: dict) -> str:
logging.info("Converting bookmarks to HTML...")
html_str: str = """<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>"""
def traverse_dict(d: dict, html_str: str, level: int) -> str:
indent: str = "\t" * level
for item in d:
if item["type"] == "folder":
html_str += f'\n{indent}<DT><H3>{item["title"]}</H3>'
html_str += f"\n{indent}<DL><p>"
html_str = traverse_dict(item["children"], html_str, level + 1)
html_str += f"\n{indent}</DL><p>"
elif item["type"] == "bookmark":
html_str += f'\n{indent}<DT><A HREF="{item["url"]}">{item["title"]}</A>'
return html_str
html_str = traverse_dict(bookmarks["bookmarks"], html_str, 1)
html_str += "\n</DL><p>"
logging.debug("HTML converted.")
return html_str
def write_html(html_content: str, output: Path = None) -> None:
logging.info("Writing HTML...")
if output is not None:
output_file: Path = output
else:
current_date: str = datetime.now().strftime("%Y_%m_%d")
output_file: Path = Path("arc_bookmarks_" + current_date).with_suffix(".html")
with output_file.open("w", encoding="utf-8") as f:
f.write(html_content)
logging.debug(f"HTML written to {output_file}.")
if __name__ == "__main__":
main()