macOS tools moving to my git
This commit is contained in:
21
arc-export/LICENSE
Normal file
21
arc-export/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Andrey Ivanov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
85
arc-export/README.md
Normal file
85
arc-export/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Arc Pinned Tabs to HTML Bookmarks Converter
|
||||
|
||||
## Overview
|
||||
|
||||
This project provides a script for converting pinned tabs in the **Arc Browser** to standard HTML bookmarks file. These bookmarks can then be imported into any web browser.
|
||||
|
||||
This addresses the lack of a pinned tabs export feature in Arc Browser.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.x
|
||||
- Arc Browser installed
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository: `git clone git@github.com:ivnvxd/arc-export.git`
|
||||
2. Navigate to the project folder: `cd arc-export`
|
||||
|
||||
or download using `curl`:
|
||||
|
||||
```sh
|
||||
curl -o main.py https://raw.githubusercontent.com/ivnvxd/arc-export/main/main.py
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run the `main.py` script from the command line:
|
||||
|
||||
```sh
|
||||
python3 main.py [-h] [-s] [-o OUTPUT] [-v] [--version]
|
||||
|
||||
# or if there is an error:
|
||||
python main.py [-h] [-s] [-o OUTPUT] [-v] [--version]
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If you encounter any problems, manually copy the `StorableSidebar.json` file from the `~/Library/Application Support/Arc/` directory to the project's directory and run the script again.
|
||||
|
||||
## Features
|
||||
The script supports various command-line options for enhanced functionality:
|
||||
|
||||
- **Show help message and exit**
|
||||
- `-h`, `--help`
|
||||
|
||||
- **Silence output**
|
||||
- `-s`, `--silent`
|
||||
|
||||
- **Specify the output file path**
|
||||
- `-o OUTPUT`, `--output OUTPUT`
|
||||
|
||||
- **Enable verbose output**
|
||||
- `-v`, `--verbose`
|
||||
|
||||
- **Print the git short hash and commit time**
|
||||
- `--version`
|
||||
|
||||
Example usage:
|
||||
|
||||
`python3 main.py -v -o my_bookmarks.html`
|
||||
|
||||

|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Read JSON**: Reads the `StorableSidebar.json` file from the Arc Browser's directory *or* the project's directory.
|
||||
2. **Convert Data**: Converts the JSON data into a hierarchical bookmarks dictionary.
|
||||
3. **Generate HTML**: Transforms the bookmarks dictionary into an HTML file.
|
||||
4. **Write HTML**: Saves the HTML file with a timestamp, allowing it to be imported into any web browser.
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are very welcome. Please submit a pull request or create an issue.
|
||||
|
||||
## Support
|
||||
|
||||
Thank you for using this project! If you find it helpful and would like to support my work, kindly consider buying me a coffee. Your support is greatly appreciated!
|
||||
|
||||
<a href="https://www.buymeacoffee.com/ivnvxd" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||
|
||||
And do not forget to give the project a star if you like it! :star:
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
BIN
arc-export/example.gif
Normal file
BIN
arc-export/example.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
311
arc-export/main.py
Executable file
311
arc-export/main.py
Executable file
@@ -0,0 +1,311 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user