From: xangelo Date: Tue, 3 Feb 2026 18:45:27 +0000 (-0500) Subject: sync Linkding bookmarks for links page X-Git-Url: https://git.xangelo.ca/?a=commitdiff_plain;h=47abee75b13e92e5479c5462b37ba2252a65f990;p=xangelo.ca.git sync Linkding bookmarks for links page --- diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index b47dadb..d5b4b43 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -4,6 +4,8 @@ on: push: branches: - main + schedule: + - cron: "0 * * * *" workflow_call: workflow_dispatch: permissions: @@ -43,6 +45,11 @@ jobs: - name: Setup Pages id: pages uses: actions/configure-pages@v4 + - name: Fetch Linkding bookmarks + env: + LINKDING_BASE_URL: ${{ secrets.LINKDING_BASE_URL }} + LINKDING_TOKEN: ${{ secrets.LINKDING_TOKEN }} + run: python3 scripts/fetch-linkding.py - name: Install Node.js dependencies run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true" - name: Build with Hugo @@ -72,4 +79,3 @@ jobs: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 - diff --git a/.gitignore b/.gitignore index 4882f35..8156495 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ public/* .DS_Store -venv/ \ No newline at end of file +venv/ +data/linkding.json diff --git a/content/links/_index.md b/content/links/_index.md new file mode 100644 index 0000000..ab902ce --- /dev/null +++ b/content/links/_index.md @@ -0,0 +1,6 @@ +--- +title: Links +date: 2026-02-03T00:00:00.000Z +--- + +Bookmarks I have shared and want to keep handy, synced from Linkding. diff --git a/layouts/links/list.html b/layouts/links/list.html new file mode 100644 index 0000000..c12dee6 --- /dev/null +++ b/layouts/links/list.html @@ -0,0 +1,95 @@ +{{ define "main" }} +
+

{{ .Title }}

+ {{ if .Content }} +
+ {{ .Content }} +
+ {{ end }} +
+
+ {{ $linkding := .Site.Data.linkding }} + {{ if and $linkding $linkding.bookmarks }} + + {{ else }} + {{ $pages := .Pages.ByDate.Reverse }} + {{ $paginator := .Paginate $pages }} + + {{ if gt $paginator.TotalPages 1 }} + + {{ end }} + {{ end }} +
+{{ end }} diff --git a/scripts/fetch-linkding.py b/scripts/fetch-linkding.py new file mode 100644 index 0000000..3aa94b3 --- /dev/null +++ b/scripts/fetch-linkding.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +import json +import os +import sys +import urllib.parse +import urllib.request +import urllib.error +from datetime import datetime, timezone + + +DATA_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "linkding.json") + + +def read_existing(): + if not os.path.exists(DATA_PATH): + return {"bookmarks": [], "last_sync": None} + with open(DATA_PATH, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def write_data(payload): + with open(DATA_PATH, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def request_json(url, token): + headers = { + "Authorization": f"Token {token}", + "User-Agent": "xangelo-linkding-sync/1.0", + "Accept": "application/json", + } + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as err: + body = err.read().decode("utf-8", errors="replace") + print(f"Request failed ({err.code}) for {url}") + if body: + print(body) + raise + + +def normalize_url(base_url, value): + if not value: + return value + if value.startswith("http://") or value.startswith("https://"): + return value + return urllib.parse.urljoin(base_url, value) + + +def main(): + base_url = os.environ.get("LINKDING_BASE_URL") + token = os.environ.get("LINKDING_TOKEN") + limit = int(os.environ.get("LINKDING_LIMIT", "100")) + + if not base_url or not token: + print("LINKDING_BASE_URL and LINKDING_TOKEN are required.") + return 1 + + base_url = base_url.rstrip("/") + + print(base_url) + print(token) + + existing = read_existing() + last_sync = existing.get("last_sync") + existing_items = {item["id"]: item for item in existing.get("bookmarks", []) if "id" in item} + + params = {"limit": str(limit), "offset": "0"} + if last_sync: + params["added_since"] = last_sync + + fetched = 0 + while True: + url = f"{base_url}/api/bookmarks/shared/?{urllib.parse.urlencode(params)}" + payload = request_json(url, token) + results = payload.get("results", []) + if not results: + break + + for item in results: + normalized = { + "id": item.get("id"), + "url": item.get("url"), + "title": item.get("title"), + "description": item.get("description"), + "notes": item.get("notes"), + "tag_names": item.get("tag_names", []), + "date_added": item.get("date_added"), + "date_modified": item.get("date_modified"), + "favicon_url": normalize_url(base_url, item.get("favicon_url")), + "preview_image_url": normalize_url(base_url, item.get("preview_image_url")), + } + if normalized.get("id") is not None: + existing_items[normalized["id"]] = normalized + fetched += 1 + + next_url = payload.get("next") + if not next_url: + break + params["offset"] = str(int(params["offset"]) + limit) + + merged = sorted( + existing_items.values(), + key=lambda item: item.get("date_added") or "", + reverse=True, + ) + now = datetime.now(timezone.utc).isoformat() + output = { + "last_sync": now, + "count": len(merged), + "bookmarks": merged, + } + write_data(output) + + print(f"Fetched {fetched} bookmark(s). Total stored: {len(merged)}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/themes/custom-mytheme/static/css/style.css b/themes/custom-mytheme/static/css/style.css index 2d0c5c5..45f5019 100644 --- a/themes/custom-mytheme/static/css/style.css +++ b/themes/custom-mytheme/static/css/style.css @@ -109,6 +109,10 @@ body { gap: 0.9rem; } +.links-list { + gap: 1.1rem; +} + .post-year { margin: 2rem 0 0.5rem; font-size: 1.2rem; @@ -129,6 +133,118 @@ body { align-items: baseline; } +.links-list .links-item { + grid-template-columns: 1fr; + align-items: start; + gap: 0.8rem; +} + +.links-body { + display: flex; + flex-direction: column; + gap: 0.4rem; + overflow: hidden; +} + +.links-footer { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.15rem; + font-family: "Courier New", Courier, monospace; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); + border-top: 1px solid var(--border); + padding-top: 0.3rem; +} + +.links-footer .post-date { + color: var(--muted); + font-size: 0.68rem; +} + +.links-footer .link-source { + color: var(--muted); + text-decoration: none; + border-bottom: 1px solid transparent; + text-transform: none; + letter-spacing: 0.02em; + word-break: break-all; + text-align: right; +} + +.links-footer .link-source:hover, +.links-footer .link-source:focus { + color: var(--accent); + border-color: var(--accent); +} + +.link-title { + font-size: 1.05rem; + line-height: 1.4; + display: inline-flex; + align-items: center; + gap: 0.6rem; +} + +.link-favicon { + width: 18px; + height: 18px; + border: 1px solid var(--border); + background: #ffffff; + border-radius: 4px; + flex-shrink: 0; +} + +.link-title-text { + display: inline-block; +} + +.post-summary { + margin: 0; + color: var(--muted); + font-size: 0.95rem; + padding: 0.4rem 0 0.4rem 0.9rem; + border-left: 4px solid var(--border); + background: #ffffff; +} + +.link-tags { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 0; +} + +.link-tag { + border: 1px solid var(--border); + padding: 0.08rem 0.4rem; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-family: "Courier New", Courier, monospace; + color: var(--muted); +} + +.link-notes { + margin: 0; + font-size: 0.95rem; + color: var(--ink); +} + +.link-preview { + width: min(180px, 40%); + height: auto; + max-height: 160px; + object-fit: cover; + border: 1px solid var(--border); + background: #ffffff; + float: left; + margin: 0.1rem 1rem 0.6rem 0; +} + .post-date { font-family: "Courier New", Courier, monospace; color: var(--muted);