init
This commit is contained in:
commit
1706b7284e
2 changed files with 318 additions and 0 deletions
305
convert.py
Normal file
305
convert.py
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert a Hoarder export into a structure that Linkwarden can import.
|
||||
|
||||
Example:
|
||||
python convert_hoarder_to_linkwarden.py hoarder_export.json -o linkwarden_import.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkRecord:
|
||||
title: str
|
||||
url: str
|
||||
description: str
|
||||
tags: List[str]
|
||||
created_dt: datetime
|
||||
|
||||
|
||||
DEFAULT_COLLECTION_NAME = "Hoarder Import"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert a Hoarder export JSON into Linkwarden's import format."
|
||||
)
|
||||
parser.add_argument(
|
||||
"hoarder_export",
|
||||
type=Path,
|
||||
help="Path to the Hoarder export JSON file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="Where to write the transformed Linkwarden JSON file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-id",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Linkwarden user identifier to attach to collections and links.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--collection-color",
|
||||
default=None,
|
||||
help="Optional hex color (e.g. #0ea5e9) to assign to generated collections.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_json(path: Path) -> Dict:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except FileNotFoundError as exc:
|
||||
sys.exit(f"Input file not found: {exc.filename}")
|
||||
except json.JSONDecodeError as exc:
|
||||
sys.exit(f"Invalid JSON in {path}: {exc}")
|
||||
|
||||
|
||||
def parse_timestamp(value, *, default_dt: Optional[datetime] = None) -> Tuple[str, datetime]:
|
||||
if default_dt is None:
|
||||
default_dt = datetime.now(timezone.utc)
|
||||
|
||||
if value is None:
|
||||
dt = default_dt
|
||||
else:
|
||||
dt = None
|
||||
if isinstance(value, (int, float)) or (isinstance(value, str) and value.isdigit()):
|
||||
try:
|
||||
dt = datetime.fromtimestamp(float(value), tz=timezone.utc)
|
||||
except (OverflowError, ValueError):
|
||||
dt = None
|
||||
if dt is None and isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if raw:
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
dt = None
|
||||
if dt is None:
|
||||
dt = default_dt
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
|
||||
iso_value = dt.isoformat().replace("+00:00", "Z")
|
||||
return iso_value, dt
|
||||
|
||||
|
||||
def normalise_tags(tags: Iterable) -> List[str]:
|
||||
cleaned: List[str] = []
|
||||
seen = set()
|
||||
for tag in tags or []:
|
||||
if not isinstance(tag, str):
|
||||
continue
|
||||
cleaned_tag = tag.strip()
|
||||
if not cleaned_tag:
|
||||
continue
|
||||
cleaned_tag = cleaned_tag.strip()
|
||||
if not cleaned_tag or cleaned_tag in seen:
|
||||
continue
|
||||
cleaned.append(cleaned_tag)
|
||||
seen.add(cleaned_tag)
|
||||
return cleaned
|
||||
|
||||
|
||||
def collect_links(hoarder_data: Dict) -> OrderedDict[str, List[LinkRecord]]:
|
||||
try:
|
||||
bookmarks = hoarder_data["bookmarks"]
|
||||
except KeyError:
|
||||
sys.exit("The Hoarder export does not contain a 'bookmarks' key.")
|
||||
if not isinstance(bookmarks, list):
|
||||
sys.exit("'bookmarks' should be a list in the Hoarder export.")
|
||||
|
||||
grouped: OrderedDict[str, List[LinkRecord]] = OrderedDict()
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
|
||||
for entry in bookmarks:
|
||||
content = entry.get("content") or {}
|
||||
if content.get("type") != "link":
|
||||
continue
|
||||
url = content.get("url")
|
||||
if not url:
|
||||
continue
|
||||
|
||||
title = entry.get("title") or url
|
||||
description = entry.get("note") or ""
|
||||
tags = normalise_tags(entry.get("tags") or [])
|
||||
_, created_dt = parse_timestamp(entry.get("createdAt"), default_dt=now_dt)
|
||||
|
||||
collection_name = DEFAULT_COLLECTION_NAME
|
||||
|
||||
grouped.setdefault(collection_name, [])
|
||||
grouped[collection_name].append(
|
||||
LinkRecord(
|
||||
title=title,
|
||||
url=url,
|
||||
description=description,
|
||||
tags=tags,
|
||||
created_dt=created_dt,
|
||||
)
|
||||
)
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
def load_base_payload(*, user_id: int) -> Dict:
|
||||
now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
return {
|
||||
"name": "",
|
||||
"username": "",
|
||||
"email": None,
|
||||
"emailVerified": None,
|
||||
"unverifiedNewEmail": None,
|
||||
"image": None,
|
||||
"locale": "en",
|
||||
"parentSubscriptionId": None,
|
||||
"collectionOrder": [],
|
||||
"linksRouteTo": "ORIGINAL",
|
||||
"aiTaggingMethod": "DISABLED",
|
||||
"aiPredefinedTags": [],
|
||||
"aiTagExistingLinks": False,
|
||||
"theme": "dark",
|
||||
"readableFontFamily": "sans-serif",
|
||||
"readableFontSize": "18px",
|
||||
"readableLineHeight": "1.6",
|
||||
"readableLineWidth": "normal",
|
||||
"preventDuplicateLinks": False,
|
||||
"archiveAsScreenshot": True,
|
||||
"archiveAsMonolith": True,
|
||||
"archiveAsPDF": True,
|
||||
"archiveAsReadable": True,
|
||||
"archiveAsWaybackMachine": False,
|
||||
"isPrivate": False,
|
||||
"referredBy": None,
|
||||
"lastPickedAt": now_iso,
|
||||
"acceptPromotionalEmails": False,
|
||||
"trialEndEmailSent": False,
|
||||
"createdAt": now_iso,
|
||||
"updatedAt": now_iso,
|
||||
"collections": [],
|
||||
"pinnedLinks": [],
|
||||
"whitelistedUsers": [],
|
||||
}
|
||||
|
||||
|
||||
def build_linkwarden_payload(
|
||||
grouped_links: OrderedDict[str, List[LinkRecord]],
|
||||
*,
|
||||
user_id: int,
|
||||
collection_color: Optional[str],
|
||||
base_payload: Dict,
|
||||
) -> Dict:
|
||||
payload = copy.deepcopy(base_payload)
|
||||
|
||||
collections = []
|
||||
link_id = 1
|
||||
collection_id = 1
|
||||
|
||||
for collection_name, link_records in grouped_links.items():
|
||||
if not link_records:
|
||||
continue
|
||||
|
||||
earliest_dt = min(record.created_dt for record in link_records)
|
||||
collection_created_iso = earliest_dt.isoformat().replace("+00:00", "Z")
|
||||
|
||||
links_payload = []
|
||||
for record in link_records:
|
||||
created_iso = record.created_dt.isoformat().replace("+00:00", "Z")
|
||||
link_payload = {
|
||||
"id": link_id,
|
||||
"name": record.title,
|
||||
"type": "url",
|
||||
"description": record.description,
|
||||
"createdById": user_id,
|
||||
"collectionId": collection_id,
|
||||
"icon": None,
|
||||
"iconWeight": None,
|
||||
"color": None,
|
||||
"url": record.url,
|
||||
"clientSide": False,
|
||||
"aiTagged": False,
|
||||
"indexVersion": None,
|
||||
"lastPreserved": None,
|
||||
"importDate": created_iso,
|
||||
"createdAt": created_iso,
|
||||
"updatedAt": created_iso,
|
||||
"tags": [{"name": tag} for tag in record.tags],
|
||||
}
|
||||
links_payload.append(link_payload)
|
||||
link_id += 1
|
||||
|
||||
collection_payload = {
|
||||
"id": collection_id,
|
||||
"name": collection_name,
|
||||
"description": "",
|
||||
"icon": None,
|
||||
"iconWeight": None,
|
||||
"color": collection_color,
|
||||
"parentId": None,
|
||||
"isPublic": False,
|
||||
"ownerId": user_id,
|
||||
"createdById": user_id,
|
||||
"createdAt": collection_created_iso,
|
||||
"updatedAt": collection_created_iso,
|
||||
"rssSubscriptions": [],
|
||||
"links": links_payload,
|
||||
}
|
||||
collections.append(collection_payload)
|
||||
collection_id += 1
|
||||
|
||||
payload["collections"] = collections
|
||||
payload.setdefault("pinnedLinks", [])
|
||||
payload.setdefault("whitelistedUsers", [])
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
|
||||
hoarder_data = load_json(args.hoarder_export)
|
||||
grouped_links = collect_links(hoarder_data)
|
||||
|
||||
if not grouped_links:
|
||||
sys.exit("No link-type bookmarks were found in the Hoarder export.")
|
||||
|
||||
base_payload = load_base_payload(user_id=args.user_id)
|
||||
|
||||
payload = build_linkwarden_payload(
|
||||
grouped_links,
|
||||
user_id=args.user_id,
|
||||
collection_color=args.collection_color,
|
||||
base_payload=base_payload,
|
||||
)
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.output.open("w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, ensure_ascii=True, indent=2)
|
||||
|
||||
total_links = sum(len(records) for records in grouped_links.values())
|
||||
print(
|
||||
f"Converted {total_links} bookmarks into {len(payload['collections'])} "
|
||||
f"Linkwarden collection(s) -> {args.output}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue