- unified importer framework (app/services/importers/): normalized model, registry, common pipeline (hierarchy, attachments, collections, dedup), async jobs, dry-run preview, column->type mapping - Phase 1: Obsidian, Notion, Logseq/Roam, HTML (Apple Notes/Bear/Ulysses/ OneNote), Google Keep, generic Markdown - Phase 2: typed CSV/TSV, Excel (openpyxl), generic JSON - Phase 3: Word .docx (python-docx), PDF (pypdf), HTML folders - Phase 4: Raindrop, Pocket, Readwise, Shaarli, Netscape bookmarks, .ics, OPML, Standard Notes, Gitea/GitHub issues (+labels/milestones) - Phase 5: incremental re-sync (skip/update/duplicate), partial-error resume, forge repo files, URL web clipper (SSRF guard), batch multi-file + UI queue, Notion relation resolution, exportable JSON reports - /import wizard, API /api/import/*, migration 9 (import_items, import_jobs) - fix: property values stored by property id (correct DB view rendering) - deps: openpyxl, beautifulsoup4, PyYAML, python-docx, pypdf - 43 import tests; full suite 491 green; ruff clean - bump version 5.11.5
285 lines
9.9 KiB
Python
285 lines
9.9 KiB
Python
"""FlowDeck — bookmark importers (v5.6.0, Phase 4).
|
|
|
|
Raindrop.io, Pocket, Readwise, Shaarli and generic Netscape bookmark files are
|
|
normalized into a FlowDeck collection (URL, description, tags, created date).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
import re
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from app.services.importers._common import coerce_tags
|
|
from app.services.importers.base import (
|
|
Importer,
|
|
ImportResult,
|
|
decode_text,
|
|
make_collection,
|
|
register_importer,
|
|
)
|
|
|
|
_SCHEMA = [
|
|
{"name": "Title", "type": "title"},
|
|
{"name": "URL", "type": "url"},
|
|
{"name": "Description", "type": "text"},
|
|
{"name": "Tags", "type": "multi_select"},
|
|
{"name": "Created", "type": "date"},
|
|
]
|
|
|
|
_TAG_RE = re.compile(
|
|
r"<h3[^>]*>(?P<folder>.*?)</h3>|<a\s+(?P<attrs>[^>]*?)>(?P<title>.*?)</a>",
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
_ATTR_RE = re.compile(r'([a-zA-Z_:-]+)\s*=\s*"([^"]*)"')
|
|
|
|
|
|
def _strip_tags(value: str) -> str:
|
|
return re.sub(r"<[^>]+>", "", value or "").strip()
|
|
|
|
|
|
def _iso_from_epoch(value: Any) -> str:
|
|
try:
|
|
return datetime.fromtimestamp(int(str(value)[:10]), tz=UTC).date().isoformat()
|
|
except (ValueError, TypeError, OSError, OverflowError):
|
|
return ""
|
|
|
|
|
|
def _iso(value: Any) -> str:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return ""
|
|
if re.fullmatch(r"\d{10}", text):
|
|
return _iso_from_epoch(text)
|
|
return text[:10] if re.match(r"^\d{4}-\d{2}-\d{2}", text) else text
|
|
|
|
|
|
def _row(title: str, url: str, description: str = "", tags=None, created: str = "") -> dict:
|
|
props: dict[str, Any] = {}
|
|
if url:
|
|
props["URL"] = url
|
|
if description:
|
|
props["Description"] = description
|
|
tag_list = coerce_tags(tags)
|
|
if tag_list:
|
|
props["Tags"] = tag_list
|
|
if created:
|
|
props["Created"] = created
|
|
return {"title": (title or url or "Bookmark").strip()[:200], "properties": props}
|
|
|
|
|
|
def parse_netscape(html: str) -> list[dict]:
|
|
"""Parse a Netscape bookmark file (browser / Pocket / Raindrop HTML)."""
|
|
rows: list[dict] = []
|
|
folder = ""
|
|
for match in _TAG_RE.finditer(html):
|
|
if match.group("folder") is not None:
|
|
folder = _strip_tags(match.group("folder"))
|
|
continue
|
|
attrs = dict(_ATTR_RE.findall(match.group("attrs") or ""))
|
|
url = attrs.get("href") or attrs.get("HREF") or ""
|
|
if not url:
|
|
continue
|
|
title = _strip_tags(match.group("title"))
|
|
tags = attrs.get("tags") or attrs.get("TAGS") or folder
|
|
rows.append(_row(title, url, tags=tags, created=_iso_from_epoch(attrs.get("add_date", ""))))
|
|
return rows
|
|
|
|
|
|
def _csv_rows(text: str) -> list[dict]:
|
|
reader = csv.DictReader(io.StringIO(text))
|
|
return [{(k or "").strip().lower(): v for k, v in row.items()} for row in reader]
|
|
|
|
|
|
class _BookmarkBase(Importer):
|
|
source_id = "bookmarks"
|
|
label = "Signets"
|
|
description = ""
|
|
order = 44
|
|
keywords: tuple[str, ...] = ()
|
|
|
|
def _hint(self, filename: str, text: str) -> bool:
|
|
low = filename.lower()
|
|
return any(k in low or k in text.lower() for k in self.keywords)
|
|
|
|
|
|
@register_importer
|
|
class RaindropImporter(_BookmarkBase):
|
|
source_id = "raindrop"
|
|
label = "Raindrop.io"
|
|
description = "Export Raindrop.io (CSV ou HTML) → collection de signets."
|
|
extensions = (".csv", ".html", ".htm")
|
|
order = 46
|
|
keywords = ("raindrop",)
|
|
|
|
def detect(self, filename: str, data: bytes) -> bool:
|
|
text = decode_text(data)
|
|
if not self._hint(filename, text):
|
|
return False
|
|
return filename.lower().endswith((".csv", ".html", ".htm"))
|
|
|
|
def parse(self, filename: str, data: bytes) -> ImportResult:
|
|
result = ImportResult(source=self.source_id)
|
|
text = decode_text(data)
|
|
rows: list[dict] = []
|
|
if filename.lower().endswith(".csv"):
|
|
for r in _csv_rows(text):
|
|
rows.append(_row(
|
|
r.get("title", ""), r.get("url", ""),
|
|
r.get("note") or r.get("excerpt") or "",
|
|
r.get("tags", ""), _iso(r.get("created", "")),
|
|
))
|
|
else:
|
|
rows = parse_netscape(text)
|
|
result.pages.append(make_collection("Raindrop", _SCHEMA, rows, source_path=filename))
|
|
result.stats["rows"] = len(rows)
|
|
return result.finalize()
|
|
|
|
|
|
@register_importer
|
|
class PocketImporter(_BookmarkBase):
|
|
source_id = "pocket"
|
|
label = "Pocket"
|
|
description = "Export Pocket (CSV ou HTML) → collection de signets."
|
|
extensions = (".csv", ".html", ".htm")
|
|
order = 45
|
|
keywords = ("pocket",)
|
|
|
|
def detect(self, filename: str, data: bytes) -> bool:
|
|
text = decode_text(data)
|
|
if not self._hint(filename, text):
|
|
return False
|
|
return filename.lower().endswith((".csv", ".html", ".htm"))
|
|
|
|
def parse(self, filename: str, data: bytes) -> ImportResult:
|
|
result = ImportResult(source=self.source_id)
|
|
text = decode_text(data)
|
|
rows: list[dict] = []
|
|
if filename.lower().endswith(".csv"):
|
|
for r in _csv_rows(text):
|
|
rows.append(_row(
|
|
r.get("title", ""), r.get("url", ""),
|
|
"", r.get("tags", ""), _iso(r.get("time_added", "")),
|
|
))
|
|
else:
|
|
rows = parse_netscape(text)
|
|
result.pages.append(make_collection("Pocket", _SCHEMA, rows, source_path=filename))
|
|
result.stats["rows"] = len(rows)
|
|
return result.finalize()
|
|
|
|
|
|
@register_importer
|
|
class ReadwiseImporter(_BookmarkBase):
|
|
source_id = "readwise"
|
|
label = "Readwise"
|
|
description = "Export Readwise (highlights CSV) → collection de surlignages."
|
|
extensions = (".csv", ".md", ".markdown")
|
|
order = 47
|
|
keywords = ("readwise",)
|
|
|
|
def detect(self, filename: str, data: bytes) -> bool:
|
|
text = decode_text(data)
|
|
if not self._hint(filename, text):
|
|
return False
|
|
if filename.lower().endswith(".csv"):
|
|
header = text.splitlines()[0].lower() if text.strip() else ""
|
|
return "highlight" in header or "book title" in header
|
|
return True
|
|
|
|
def parse(self, filename: str, data: bytes) -> ImportResult:
|
|
result = ImportResult(source=self.source_id)
|
|
text = decode_text(data)
|
|
schema = [
|
|
{"name": "Highlight", "type": "title"},
|
|
{"name": "Book", "type": "text"},
|
|
{"name": "Author", "type": "text"},
|
|
{"name": "Note", "type": "text"},
|
|
{"name": "Tags", "type": "multi_select"},
|
|
{"name": "Highlighted at", "type": "date"},
|
|
]
|
|
rows: list[dict] = []
|
|
if filename.lower().endswith(".csv"):
|
|
for r in _csv_rows(text):
|
|
props: dict[str, Any] = {}
|
|
if r.get("book title"):
|
|
props["Book"] = r["book title"]
|
|
if r.get("book author"):
|
|
props["Author"] = r["book author"]
|
|
if r.get("note"):
|
|
props["Note"] = r["note"]
|
|
tags = coerce_tags(r.get("document tags") or r.get("tags"))
|
|
if tags:
|
|
props["Tags"] = tags
|
|
created = _iso(r.get("highlighted at", ""))
|
|
if created:
|
|
props["Highlighted at"] = created
|
|
rows.append({"title": (r.get("highlight") or "Highlight").strip()[:200], "properties": props})
|
|
else:
|
|
rows = [{"title": ln.lstrip("-* ").strip()[:200], "properties": {}} for ln in text.splitlines() if ln.strip()]
|
|
result.pages.append(make_collection("Readwise", schema, rows, source_path=filename))
|
|
result.stats["rows"] = len(rows)
|
|
return result.finalize()
|
|
|
|
|
|
@register_importer
|
|
class ShaarliImporter(_BookmarkBase):
|
|
source_id = "shaarli"
|
|
label = "Shaarli"
|
|
description = "Export Shaarli (JSON) → collection de signets."
|
|
extensions = (".json",)
|
|
order = 48
|
|
keywords = ("shaarli",)
|
|
|
|
def _records(self, data: bytes) -> list[dict] | None:
|
|
try:
|
|
obj = json.loads(decode_text(data))
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
if isinstance(obj, dict) and isinstance(obj.get("links"), list):
|
|
obj = obj["links"]
|
|
if isinstance(obj, list) and obj and all(isinstance(x, dict) and x.get("url") for x in obj):
|
|
return obj
|
|
return None
|
|
|
|
def detect(self, filename: str, data: bytes) -> bool:
|
|
if not filename.lower().endswith(".json"):
|
|
return False
|
|
return self._records(data) is not None
|
|
|
|
def parse(self, filename: str, data: bytes) -> ImportResult:
|
|
result = ImportResult(source=self.source_id)
|
|
records = self._records(data) or []
|
|
rows = [
|
|
_row(r.get("title", ""), r.get("url", ""), r.get("description", ""),
|
|
r.get("tags", ""), _iso(r.get("created", "")))
|
|
for r in records
|
|
]
|
|
result.pages.append(make_collection("Shaarli", _SCHEMA, rows, source_path=filename))
|
|
result.stats["rows"] = len(rows)
|
|
return result.finalize()
|
|
|
|
|
|
@register_importer
|
|
class BookmarksImporter(_BookmarkBase):
|
|
source_id = "bookmarks"
|
|
label = "Signets HTML (navigateur)"
|
|
description = "Fichier de signets Netscape HTML (Chrome/Firefox/Edge…)."
|
|
extensions = (".html", ".htm")
|
|
order = 49
|
|
keywords = ()
|
|
|
|
def detect(self, filename: str, data: bytes) -> bool:
|
|
if not filename.lower().endswith((".html", ".htm")):
|
|
return False
|
|
text = decode_text(data)[:4000].lower()
|
|
return "netscape-bookmark-file" in text or "<dt><a href" in text
|
|
|
|
def parse(self, filename: str, data: bytes) -> ImportResult:
|
|
result = ImportResult(source=self.source_id)
|
|
rows = parse_netscape(decode_text(data))
|
|
result.pages.append(make_collection("Bookmarks", _SCHEMA, rows, source_path=filename))
|
|
result.stats["rows"] = len(rows)
|
|
return result.finalize()
|