sync Linkding bookmarks for links page linkding-links-page
authorxangelo <me@xangelo.ca>
Tue, 3 Feb 2026 18:45:27 +0000 (13:45 -0500)
committerxangelo <me@xangelo.ca>
Tue, 3 Feb 2026 18:45:27 +0000 (13:45 -0500)
.github/workflows/hugo.yml
.gitignore
content/links/_index.md [new file with mode: 0644]
layouts/links/list.html [new file with mode: 0644]
scripts/fetch-linkding.py [new file with mode: 0644]
themes/custom-mytheme/static/css/style.css

index b47dadb00114335d5e643edc2e61707fe641929e..d5b4b4362048e552ff11e2a8db878ed65270b8ec 100644 (file)
@@ -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
-
index 4882f35f8e1b38e99da9657af9baf0d88a265b39..8156495c740693dcafa3624b0a62fcc1bc051d01 100644 (file)
@@ -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 (file)
index 0000000..ab902ce
--- /dev/null
@@ -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 (file)
index 0000000..c12dee6
--- /dev/null
@@ -0,0 +1,95 @@
+{{ define "main" }}
+  <section class="list-header">
+    <h1>{{ .Title }}</h1>
+    {{ if .Content }}
+      <div class="list-content">
+        {{ .Content }}
+      </div>
+    {{ end }}
+  </section>
+  <section class="list-items">
+    {{ $linkding := .Site.Data.linkding }}
+    {{ if and $linkding $linkding.bookmarks }}
+      <ul class="post-list links-list">
+        {{ range $linkding.bookmarks }}
+          <li class="links-item">
+            {{ $target := .url }}
+            <div class="links-body">
+              <a class="link-title" href="{{ .url }}" rel="noopener" target="_blank">
+                {{ if .favicon_url }}
+                  <img class="link-favicon" src="{{ .favicon_url }}" alt="" loading="lazy" decoding="async">
+                {{ end }}
+                <span class="link-title-text">{{ .title }}</span>
+              </a>
+              {{ if .description }}
+                {{ if .preview_image_url }}
+                  <img class="link-preview" src="{{ .preview_image_url }}" alt="" loading="lazy" decoding="async">
+                {{ end }}
+              {{ end }}
+              {{ if .notes }}
+                <p class="link-notes">{{ .notes }}</p>
+              {{ end }}
+              {{ if .description }}
+                <p class="post-summary">{{ .description }}</p>
+              {{ end }}
+            </div>
+            <div class="links-footer">
+              <a class="link-source" href="{{ $target }}" rel="noopener" target="_blank">{{ $target }}</a>
+              {{ if .tag_names }}
+                <div class="link-tags">
+                  {{ range .tag_names }}
+                    <span class="link-tag">{{ . }}</span>
+                  {{ end }}
+                </div>
+              {{ end }}
+              {{ if .date_added }}
+                <span class="post-date">{{ (time .date_added).Format "2006-01-02" }}</span>
+              {{ end }}
+            </div>
+          </li>
+        {{ end }}
+      </ul>
+    {{ else }}
+      {{ $pages := .Pages.ByDate.Reverse }}
+      {{ $paginator := .Paginate $pages }}
+      <ul class="post-list links-list">
+        {{ range $paginator.Pages }}
+          <li class="links-item">
+            {{ $link := .Params.link }}
+            {{ $target := $link | default .RelPermalink }}
+            <div class="links-body">
+              {{ if $link }}
+                {{ $host := replaceRE "^https?://([^/]+).*$" "$1" $link }}
+                <a class="link-title" href="{{ $link }}" rel="noopener" target="_blank">
+                  <img class="link-favicon" src="https://www.google.com/s2/favicons?domain={{ $host }}&sz=32" alt="" loading="lazy" decoding="async">
+                  <span class="link-title-text">{{ .Title }}</span>
+                </a>
+              {{ else }}
+                <a class="link-title" href="{{ .RelPermalink }}">{{ .Title }}</a>
+              {{ end }}
+              {{ if .Content }}
+                {{ $summary := .Plain | replaceRE "\r+" " " | replaceRE "\n+" " " | truncate 260 }}
+                <p class="post-summary">{{ $summary }}</p>
+              {{ end }}
+            </div>
+            <div class="links-footer">
+              <a class="link-source" href="{{ $target }}" rel="noopener" target="_blank">{{ $target }}</a>
+              <span class="post-date">{{ .Date.Format "2006-01-02" }}</span>
+            </div>
+          </li>
+        {{ end }}
+      </ul>
+      {{ if gt $paginator.TotalPages 1 }}
+        <nav class="pagination">
+          {{ if $paginator.HasPrev }}
+            <a class="pagination-prev" href="{{ $paginator.Prev.URL }}">Newer</a>
+          {{ end }}
+          <span class="pagination-page">Page {{ $paginator.PageNumber }} of {{ $paginator.TotalPages }}</span>
+          {{ if $paginator.HasNext }}
+            <a class="pagination-next" href="{{ $paginator.Next.URL }}">Older</a>
+          {{ end }}
+        </nav>
+      {{ end }}
+    {{ end }}
+  </section>
+{{ end }}
diff --git a/scripts/fetch-linkding.py b/scripts/fetch-linkding.py
new file mode 100644 (file)
index 0000000..3aa94b3
--- /dev/null
@@ -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())
index 2d0c5c5ab2a6ac5b0f45ebccf77436aeda5cacb2..45f50191131c2e9c24ba620a771d59a774af03a8 100644 (file)
@@ -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);