"""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"]*>(?P.*?)|[^>]*?)>(?P.*?)</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()