push:
branches:
- main
+ schedule:
+ - cron: "0 * * * *"
workflow_call:
workflow_dispatch:
permissions:
- 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
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
-
public/*
.DS_Store
-venv/
\ No newline at end of file
+venv/
+data/linkding.json
--- /dev/null
+---
+title: Links
+date: 2026-02-03T00:00:00.000Z
+---
+
+Bookmarks I have shared and want to keep handy, synced from Linkding.
--- /dev/null
+{{ 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 }}
--- /dev/null
+#!/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())
gap: 0.9rem;
}
+.links-list {
+ gap: 1.1rem;
+}
+
.post-year {
margin: 2rem 0 0.5rem;
font-size: 1.2rem;
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);