diff --git a/.gitignore b/.gitignore index 97fcbf60..b467436f 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ tailwind.log tailwindcss-*.log static/config.live.json scripts/probe* +.vscode/settings.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ab695f4f..ad5f5d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Keine offenen Eintraege. | Datum | Was | Details | |---|---|---| +| 2026-02-21 | Kurzlink-Feature (PR #122): Dezentraler URL-Shortener via Nostr Kind 30491 (ShareDialog, /b/[slug] Route) | [2026-Q1.md](docs/CHANGELOG/2026-Q1.md) | | 2026-02-21 | Automatische Gespraechs-Zusammenfassung (LLM + lokaler Fallback) | [2026-Q1.md](docs/CHANGELOG/2026-Q1.md) | | 2026-02-21 | LLM Proxy 400-Fehler behoben (tool_choice, Umlaute, Retry) | [2026-Q1.md](docs/CHANGELOG/2026-Q1.md) | | 2026-02-20 | Paste-System: Editable-Target Guard | [2026-Q1.md](docs/CHANGELOG/2026-Q1.md) | diff --git a/README.md b/README.md index e8465c55..65e918e1 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Ein intelligentes Kanban-Board mit KI-Unterstützung und Nostr-Integration, geba - 📱 **Reaktiv:** Svelte 5 Runes für optimale Performance - 🔒 **Typsicher:** Vollständig in TypeScript implementiert - 🎨 **Demo-Boards:** Sofortiger Start für anonyme Nutzer mit vorkonfigurierten Boards +- 🔗 **Kurzlinks:** Dezentraler URL-Shortener via Nostr — merkbare Board-URLs statt langer naddr-Strings ## 📚 Dokumentation diff --git a/docs/CHANGELOG/2026-Q1.md b/docs/CHANGELOG/2026-Q1.md index 3dd62f65..a85697ff 100644 --- a/docs/CHANGELOG/2026-Q1.md +++ b/docs/CHANGELOG/2026-Q1.md @@ -13,6 +13,7 @@ Q1/2026 war kein einzelner Release, sondern ein schneller Stabilitaets- und Ausb 4. Betrieb und Sicherheit gehaertet (OIDC Fixes, Docker Publishing, Event-Authorisierung). 5. LLM Proxy stabilisiert (tool_choice Fix, Umlaut-Bereinigung, Retry-Logik). 6. Automatische Gespraechs-Zusammenfassung fuer KI-Chat eingefuehrt. +7. Kurzlink-Feature: Dezentraler URL-Shortener via Nostr Kind 30491. ## Zeitstrahl in Clustern @@ -22,7 +23,7 @@ Q1/2026 war kein einzelner Release, sondern ein schneller Stabilitaets- und Ausb | 2026-01-25 bis 2026-01-31 | Paste/Import, OER- und AI-MCP-Verbesserungen, Inline Editing | Schnellere Inhaltserfassung und effizientere Bearbeitung | | 2026-02-01 bis 2026-02-05 | Communikey/Sharing, Mobile-Polish, Sync-Hotfix-Welle | Stabileres kollaboratives Arbeiten, bessere Mobile-Erfahrung | | 2026-02-06 bis 2026-02-20 | OIDC-Fixes, Docker CI, Fremd-Board/Permalink, Shared-Sync, Security Guards | Robustere Sessions, bessere Deploybarkeit, hoehere Datensicherheit | -| 2026-02-21 | LLM Proxy Fix, Auto-Summarization, Paste Guard | Stabilere und kontextreichere KI-Interaktion | +| 2026-02-21 | LLM Proxy Fix, Auto-Summarization, Paste Guard, Kurzlink-Feature | Stabilere und kontextreichere KI-Interaktion, dezentraler URL-Shortener | ## Wichtige Ergebnisbloecke (Versionen) @@ -34,12 +35,13 @@ Q1/2026 war kein einzelner Release, sondern ein schneller Stabilitaets- und Ausb | Shared-Board Einstieg | 4.7.29 bis 4.7.26 (01.02.2026) | Follow/Visibility/Share-Dialog stabiler | | Content-Pipelines | 4.7.25 bis 4.7.19 (26.01-31.01.2026) | Paste, OER-Suche und AI-Workflows besser integriert | | Nach 4.7.96 (Git/PR) | 06.02-20.02.2026 | Security, OIDC, Shared-Sync, Permalink/Follow weiter gehaertet | -| Direct Pushes | 20.02-21.02.2026 | LLM Proxy Fix, Auto-Summarization, Paste Guard | +| Direct Pushes + PR | 20.02-21.02.2026 | LLM Proxy Fix, Auto-Summarization, Paste Guard, Kurzlink (PR #122) | ## PR-Highlights mit direktem Nutzen | Datum | PR | Thema | Nutzen | |---|---|---|---| +| 2026-02-21 | [#122](https://github.com/edufeed-org/kanban-editor/pull/122) | Kurzlink-Feature (URL Shortener) | Boards via kurzer URL teilbar (`/b/slug`), QR-Codes deutlich kompakter, dezentral via Nostr Kind 30491 | | 2026-02-21 | Direct | Automatische Gespraechs-Zusammenfassung | Chat-History wird nach 6 Nachrichten per LLM zusammengefasst — besserer Kontext bei gleichem Token-Budget | | 2026-02-21 | Direct | LLM Proxy 400-Fehler behoben | `tool_choice` von `required` auf `auto`, Umlaut-Bereinigung, Retry-Logik | | 2026-02-20 | Direct | Paste-System: Editable-Target Guard erweitert | Paste-Events werden nicht mehr in Input/Textarea/TipTap abgefangen | @@ -126,6 +128,7 @@ Q1/2026 war kein einzelner Release, sondern ein schneller Stabilitaets- und Ausb ## Direkte Commits (ohne PR) ### 2026-02-21 +- **Kurzlink-Feature (PR [#122](https://github.com/edufeed-org/kanban-editor/pull/122), URL Shortener via Nostr Kind 30491):** Boards koennen ueber kurze, merkbare URLs geteilt werden (z.B. `/b/mein-projekt` statt langer naddr-String). Slug wird automatisch aus Board-Name generiert (mit Umlaut-Behandlung), ist manuell editierbar. Auto-Publish beim ersten Klick auf Copy/Open/QR. Resolver-Route `/b/[slug]` laedt Shortlink-Event von Nostr und leitet zum Board weiter. 26 Unit-Tests (inkl. Resolve-Funktionen mit NDK-Mocking). Betroffene Dateien: `nostrEvents.ts`, `kanbanStore.svelte.ts`, `ShareDialog.svelte`, `src/routes/b/[slug]/`. Feature-Doku: `docs/FEATURE/SHORTLINK.md`. - **Automatische Gespraechs-Zusammenfassung:** Chat-History wird nach 6 Nachrichten (3 Runden) automatisch per LLM zusammengefasst. Statt harter 3-Nachrichten-Grenze erhaelt das LLM [Zusammenfassung] + aktuelle Nachrichten — besserer Kontext bei gleichem Token-Budget. Lokaler Fallback bei LLM-Fehler. Nutzt `ConversationSummary` aus ChatModel.ts. Siehe `docs/ARCHITECTURE/AGENT/TOOL-BASED-AI.md` Section VIII. - **LLM Proxy 400-Fehler behoben:** `tool_choice` von `required` auf `auto` umgestellt — eliminiert systematische 400-Fehler bei 16 Tools + Chat-History. Synthetischer `respond`-Fallback fuer reine Text-Antworten. Umlaute in Tool-Definitionen/System-Prompt durch ASCII ersetzt. Retry-Logik und Budget-Fallback-Loop. Siehe `docs/ARCHITECTURE/AGENT/LLM-PROXY-INVESTIGATION.md`. @@ -159,6 +162,7 @@ git log ^1..^2 --pretty=format:"%h|%ad|%s" --date=short | Datum | PR | Branch | Merge Commit | |---|---|---|---| +| 2026-02-21 | [#122](https://github.com/edufeed-org/kanban-editor/pull/122) | feature/urlshortener | — | | 2026-02-20 | [#121](https://github.com/edufeed-org/kanban-editor/pull/121) | edufeed-org/security-fix | cec26f4 | | 2026-02-20 | [#120](https://github.com/edufeed-org/kanban-editor/pull/120) | JannikStreek/feat/oer-finder-plugin-local-search | ec92467 | | 2026-02-19 | [#119](https://github.com/edufeed-org/kanban-editor/pull/119) | edufeed-org/fix/editshared | 34944fa | diff --git a/docs/FEATURE/SHORTLINK.md b/docs/FEATURE/SHORTLINK.md new file mode 100644 index 00000000..5ab25004 --- /dev/null +++ b/docs/FEATURE/SHORTLINK.md @@ -0,0 +1,251 @@ +# 🔗 Kurzlink-Feature (URL Shortener via Nostr) + +**Version:** 1.0 +**Status:** ✅ COMPLETE (26 Unit-Tests) +**Datum:** 21. Februar 2026 +**Branch:** `feature/urlshortener` + +--- + +## 📋 Übersicht & Motivation + +### Das Problem + +Die vollständigen Board-URLs enthalten einen langen `naddr`-String (Nostr Addressable Identifier), z.B.: + +``` +https://kanban.edufeed.org/cardsboard/naddr1qqpkucttqy28wumn8ghj7un9d3shjtn... +``` + +Solche URLs sind: +- ❌ Schwer mündlich zu kommunizieren +- ❌ Nicht merkbar +- ❌ QR-Codes werden unnötig groß und schwer scanbar +- ❌ Ungeeignet für Social-Media-Posts + +### Die Lösung + +Dezentrale Kurzlinks über **Nostr Addressable Events (Kind 30491)** nach NIP-33. +Ein kurzer Slug wird auf den vollen `naddr`-String gemappt und auf Nostr-Relays publiziert. + +``` +Vorher: https://kanban.edufeed.org/cardsboard/naddr1qqpkucttqy28wumn8ghj7... +Nachher: https://kanban.edufeed.org/b/mein-projekt +``` + +**Vorteile:** +- 🚀 Kurze, merkbare URLs +- 🌐 Dezentral — kein URL-Shortener-Service nötig +- 🔁 Deterministisch — Slug wird aus Board-Name generiert +- ✏️ Editierbar — Nutzer kann Slug vor Publizierung anpassen +- 📱 Kompakte QR-Codes + +--- + +## 🚀 Quick Start (Benutzer-Anleitung) + +### Kurzlink erstellen + +1. Board öffnen → **Share-Button** (Toolbar oben rechts) klicken +2. Im Dialog ist der **Kurzlink-Tab** bereits aktiv +3. Ein Slug wird automatisch aus dem Board-Namen generiert (z.B. `mein-projekt`) +4. Optional: Slug im Eingabefeld bearbeiten +5. Auf **Kopieren**, **Öffnen** oder **QR-Code** klicken + - Der Kurzlink wird beim ersten Klick automatisch auf Nostr publiziert + - Danach wird die gewählte Aktion ausgeführt +6. Grüne ✓-Markierung zeigt: Slug ist publiziert + +### Kurzlink verwenden + +Jeder mit dem Link (z.B. `https://kanban.edufeed.org/b/mein-projekt`) wird automatisch zum Board weitergeleitet. + +--- + +## 🔧 Technische Architektur + +### Nostr Event-Struktur (Kind 30491) + +| Feld | Wert | Beschreibung | +|------|------|--------------| +| `kind` | `30491` | Addressable Event (NIP-33) | +| `d`-Tag | Slug | z.B. `"mein-projekt"` — macht Event per Slug adressierbar | +| `r`-Tag | naddr-String | Maschinenlesbarer Board-Link | +| `a`-Tag | `30301::` | Querverweis zum Board-Event | +| `title`-Tag | Board-Titel | Für Discovery/Suche | +| `content` | naddr-String | Human-Readable Fallback | + +**Beispiel-Event:** +```json +{ + "kind": 30491, + "tags": [ + ["d", "mein-projekt"], + ["r", "naddr1qqpkucttqy28wumn8ghj7..."], + ["a", "30301:abc123:board-d-tag"], + ["title", "Mein Projekt"] + ], + "content": "naddr1qqpkucttqy28wumn8ghj7..." +} +``` + +### Datenfluss + +``` +ShareDialog (UI) + │ + ├─ slugifyBoardName() → Auto-Slug aus Board-Name + │ + ├─ [User bearbeitet Slug optional] + │ + └─ ensurePublished() → Beim Klick auf Copy/Open/QR + │ + ├─ boardStore.publishShortlink(slug) + │ ├─ nip19.naddrEncode() → naddr generieren + │ ├─ createShortlinkEvent() → Kind 30491 Event + │ └─ event.publish() → Auf öffentliche Relays + │ + └─ Aktion ausführen (Copy/Open/QR) +``` + +### Resolver-Route (`/b/[slug]`) + +``` +Browser: /b/mein-projekt + │ + ├─ +page.ts → slug aus URL extrahieren, prerender=false + │ + └─ +page.svelte (onMount) + ├─ NDK-Bereitschaft abwarten (max. 5s) + ├─ resolveShortlinkBySlug(slug, ndk) + │ ├─ Kind 30491, #d=[slug] auf Nostr suchen + │ └─ Last-Write-Wins bei mehreren Ergebnissen + └─ goto('/cardsboard/', { replaceState: true }) +``` + +**Status-States der Resolver-Seite:** +- `loading` — Slug wird auf Nostr gesucht (mit Fortschrittsanzeige) +- `not-found` — Kein Event mit diesem Slug gefunden +- `error` — NDK nicht bereit oder anderer Fehler + +--- + +## 📁 Betroffene Dateien + +| Datei | Änderung | +|-------|----------| +| `src/lib/utils/nostrEvents.ts` | 5 neue Funktionen + `EVENT_KINDS.SHORTLINK = 30491` | +| `src/lib/stores/kanbanStore.svelte.ts` | `publishShortlink(slug)` Methode | +| `src/lib/components/board/ShareDialog.svelte` | Kurzlink-Tab mit Auto-Publish UX | +| `src/routes/b/[slug]/+page.ts` | SvelteKit Load-Funktion | +| `src/routes/b/[slug]/+page.svelte` | Resolver-Seite (Loading/Error/NotFound) | +| `src/lib/utils/nostrEvents.spec.ts` | 17 neue Unit-Tests | + +--- + +## 📚 API-Referenz + +### `slugifyBoardName(boardName: string): string` + +Generiert einen URL-freundlichen Slug aus einem Board-Namen. + +- Umlaute → ASCII (ä→ae, ö→oe, ü→ue, ß→ss) — **vor** NFD-Normalisierung +- Diakritische Zeichen entfernen +- Nicht-alphanumerische Zeichen → Bindestrich +- Max. 48 Zeichen + +```typescript +slugifyBoardName('Mein Tolles Board') // → "mein-tolles-board" +slugifyBoardName('Übung für Schüler') // → "uebung-fuer-schueler" +slugifyBoardName('Café & Résumé') // → "cafe-resume" +``` + +### `createShortlinkEvent(slug, naddr, boardId, authorPubkey, boardTitle?, ndk): NDKEvent` + +Erstellt ein unsigniertes Kind 30491 Event. + +| Parameter | Typ | Beschreibung | +|-----------|-----|--------------| +| `slug` | `string` | Das Kürzel (wird zum d-Tag) | +| `naddr` | `string` | Vollständiger naddr-String | +| `boardId` | `string` | Board d-Tag | +| `authorPubkey` | `string` | Hex-Pubkey des Board-Autors | +| `boardTitle` | `string \| undefined` | Optionaler Board-Titel | +| `ndk` | `NDK` | NDK-Instanz | + +### `resolveShortlink(slug, authorPubkey, ndk): Promise` + +Löst einen Slug auf, wenn der Author bekannt ist (schneller, gezielter Filter). + +### `resolveShortlinkBySlug(slug, ndk): Promise<{ naddr, authorPubkey } | null>` + +Löst einen Slug auf **ohne** Author-Kenntnis. Sucht über alle Autoren, nimmt das neueste Event (Last-Write-Wins). + +### `boardStore.publishShortlink(slug): Promise` + +Publiziert ein Shortlink-Event für das aktuelle Board. +- Generiert naddr via `nip19.naddrEncode()` +- Publiziert auf öffentliche Relays +- Gibt `true` bei Erfolg zurück + +--- + +## 🧪 Testing + +### Unit-Tests (17 Tests in `nostrEvents.spec.ts`) + +``` +✅ slugifyBoardName + - Lowercase + Bindestriche + - Umlaute (ä→ae, ö→oe, ü→ue, ß→ss) + - Diakritische Zeichen (Café → cafe) + - Max. 48 Zeichen + - Leerer String + - Sonderzeichen + +✅ createShortlinkEvent + - Kind 30491, d/r/a-Tags + - Without title → kein title-Tag + - Content = naddr + +✅ resolveShortlink (4 Tests) + - Erfolgreiche Auflösung via r-Tag + - Fallback auf Content wenn kein r-Tag + - Nicht gefunden → null + - Leerer Content ohne r-Tag → null + +✅ resolveShortlinkBySlug (5 Tests) + - Erfolgreiche Auflösung ohne Author-Kenntnis + - Nicht gefunden (leere Menge) → null + - Last-Write-Wins bei 3 konkurrierenden Events + - Fallback auf Content ohne r-Tag + - fetchEvents → null → null +``` + +--- + +## ⚠️ Fehlerbehebung + +### Kurzlink wird nicht gefunden + +**Mögliche Ursachen:** +- Board-Autor ist nicht angemeldet (Shortlink braucht signiertes Event) +- Relays sind nicht erreichbar +- Slug wurde noch nicht publiziert (grüne ✓-Markierung fehlt) + +### QR-Code wird nicht generiert + +- QR-Button klicken → Shortlink wird automatisch publiziert → QR wird erzeugt +- Wenn Slug geändert wird, muss QR erneut generiert werden + +### Slug-Kollision + +Addressable Events (NIP-33) sind pro Author + d-Tag unique. Verschiedene Autoren können denselben Slug haben. `resolveShortlinkBySlug` nimmt das neueste Event (Last-Write-Wins). + +--- + +## 🔗 Referenzen + +- [Nostr NIP-33: Addressable Events](https://github.com/nostr-protocol/nips/blob/master/33.md) +- [NIP-19: naddr Encoding](https://github.com/nostr-protocol/nips/blob/master/19.md) +- [Share-Link Feature (Token-basiert)](./SHARELINK.md) — Verwandtes Feature für Board-Sharing via komprimiertem Token +- [Kanban-NIP Event Schema](../../Kanban-NIP.md) — Kind 30301/30302 Board/Card Events diff --git a/docs/_INDEX.md b/docs/_INDEX.md index 4b0d7e91..7da9e6bf 100644 --- a/docs/_INDEX.md +++ b/docs/_INDEX.md @@ -409,10 +409,11 @@ docs/ | [`GUIDE.md`](./TESTS/GUIDE.md) | Ausführliches Test-Guide | ✅ | | [`STATUS.md`](./TESTS/STATUS.md) | Test Suite Status & Überblick | ✅ | -### FEATURE/ (13 Dateien) +### FEATURE/ (14 Dateien) | Datei | Zweck | Status | |-------|-------|--------| +| [`SHORTLINK.md`](./FEATURE/SHORTLINK.md) | 🆕 **NEU (21.02.26)**: Dezentraler URL-Shortener via Nostr Kind 30491 | ✅ Neu (21.02.26) | | [`LANDINGPAGE.md`](./FEATURE/LANDINGPAGE.md) | Landingpage für das Kanban-Board (CTA, Links, Lehrkräfte-Fokus) | ✅ Neu (03.02.) | | [`TOOL-BASED-AI.md`](./AGENT/TOOL-BASED-AI.md) | 🆕 **NEU (21.01.26)**: MCP-Style Tool-Based KI | [`OER-FINDER-CHAT-BOT-INTEGRATION.md`](./AGENT/OER-FINDER-CHAT-BOT-INTEGRATION.md) | OER-Finder Integration für Chatbot fügt OER-Content hinzu ✅ Neu | diff --git a/src/lib/components/board/ShareDialog.svelte b/src/lib/components/board/ShareDialog.svelte index 8fe02791..dd7ef48e 100644 --- a/src/lib/components/board/ShareDialog.svelte +++ b/src/lib/components/board/ShareDialog.svelte @@ -11,12 +11,13 @@ import TrashIcon from "@lucide/svelte/icons/trash"; import CopyIcon from "@lucide/svelte/icons/copy"; import CheckIcon from "@lucide/svelte/icons/check"; - import LinkIcon from "@lucide/svelte/icons/link"; import ExternalLinkIcon from "@lucide/svelte/icons/external-link"; import { toast } from "svelte-sonner"; - import { createBoardNaddr, createBoardNaddrUrl } from "$lib/utils/nostrEvents"; + import { createBoardNaddr, createBoardNaddrUrl, slugifyBoardName } from "$lib/utils/nostrEvents"; import { nip19 } from '@nostr-dev-kit/ndk'; import QRCode from 'qrcode'; + import LoaderCircleIcon from "@lucide/svelte/icons/loader-circle"; + import QrCodeIcon from "@lucide/svelte/icons/qr-code"; // Props let { @@ -34,31 +35,33 @@ let activeTab = $state('nostr-link'); // 'nostr-link' | 'share-link' | 'editors' let linkCopied = $state(false); - // Nostr naddr Link State + // Nostr naddr Link State (intern für Shortlink-Generierung) let naddrPath = $state(''); // Relativer Pfad: /cardsboard/naddr... - let naddrCopied = $state(false); - let qrCodeDataUrl = $state(''); + + // Kurzlink State + let shortlinkSlug = $state(''); + let shortlinkSlugEdited = $state(false); // User hat Slug manuell bearbeitet + let isPublishingShortlink = $state(false); + let publishedSlug = $state(''); // Zuletzt erfolgreich publizierter Slug + let shortlinkPublished = $derived(publishedSlug !== '' && publishedSlug === shortlinkSlug); + let shortlinkCopied = $state(false); + let shortlinkQrDataUrl = $state(''); // Base-URL für vollständige Links (default: Origin + BASE_URL) let baseUrl = $state( typeof window !== 'undefined' ? (() => { - const envBase = import.meta.env.BASE_URL || '/'; - let basePath = envBase; - - // Vite kann BASE_URL als "./" setzen (z.B. GitHub Pages). Dann aktuelles Path-Segment verwenden. + let basePath = import.meta.env.BASE_URL || ''; if (basePath === '.' || basePath === './') { - basePath = window.location.pathname.replace(/[^/]*$/, ''); + // Fallback für relative Pfade: entferne /cardsboard und alles danach + basePath = window.location.pathname.replace(/\/cardsboard.*$/, ''); } - - // Normalisieren: führenden Slash sicherstellen - if (!basePath.startsWith('/')) { - basePath = `/${basePath}`; + if (basePath === '/') { + basePath = ''; } - - const resolved = new URL(basePath, window.location.origin); - const normalizedPath = resolved.pathname.replace(/\/$/, ''); - return `${resolved.origin}${normalizedPath}`; + // Normalisieren: trailing slash entfernen + basePath = basePath.replace(/\/$/, ''); + return `${window.location.origin}${basePath}`; })() : 'http://localhost:5173' ); @@ -66,6 +69,9 @@ // Vollständiger naddr-Link (kombiniert baseUrl + naddrPath) let naddrLink = $derived(naddrPath ? `${baseUrl}${naddrPath}` : ''); + // Vollständiger Kurzlink (kombiniert baseUrl + Slug) + let shortlinkUrl = $derived(shortlinkSlug ? `${baseUrl}/b/${shortlinkSlug}` : ''); + let userRole = $state(BoardRole.VIEWER); let canInviteEditors = $derived(userRole === BoardRole.OWNER); let showLinkTabs = $derived(mode !== 'editors'); @@ -101,7 +107,7 @@ let shareToken = $state(''); // Vollständiger Share-Link (kombiniert baseUrl + Token) - let fullShareLink = $derived(shareToken ? `${baseUrl}${import.meta.env.BASE_URL}cardsboard?import=${shareToken}` : ''); + let fullShareLink = $derived(shareToken ? `${baseUrl}/cardsboard?import=${shareToken}` : ''); async function loadShareConfig(): Promise { @@ -450,17 +456,6 @@ } }); - // QR-Code nur für Nostr-Link neu generieren - $effect(() => { - if (!open || activeTab !== 'nostr-link' || mode === 'editors') return; - const currentBaseUrl = baseUrl; - const currentPath = naddrPath; - if (currentBaseUrl && currentPath) { - void regenerateQrCode(); - } - }); - - $effect(() => { if (!open) return; @@ -476,71 +471,102 @@ } }); - // Nostr naddr-Link generieren + // Nostr naddr-Link generieren (intern für Shortlink-Erstellung) async function generateNaddrLink() { const board = boardStore.data; if (!board || !board.author) { naddrPath = ''; - qrCodeDataUrl = ''; return; } try { - // Relay-Hints aus den öffentlichen Relays holen const relayHints: string[] = settingsStore.settings.relaysPublic || []; - // Hinweis: console.log von $state-Proxies vermeiden - naddrPath = createBoardNaddrUrl(board.id, board.author, relayHints); - - // QR-Code mit vollständiger URL generieren - await regenerateQrCode(); } catch (error) { console.error('Fehler beim Generieren des naddr-Links:', error); naddrPath = ''; - qrCodeDataUrl = ''; } } - // QR-Code neu generieren (wenn baseUrl oder naddrPath sich ändert) - async function regenerateQrCode() { - if (!naddrLink) { - qrCodeDataUrl = ''; - return; + // ── Kurzlink Logik ── + + // Auto-Slug generieren wenn Dialog öffnet und Slug nicht manuell bearbeitet + $effect(() => { + if (open && !shortlinkSlugEdited) { + const boardName = boardStore.data?.name; + if (boardName) { + shortlinkSlug = slugifyBoardName(boardName); + } } + }); + + function handleSlugInput(e: Event) { + const input = e.target as HTMLInputElement; + // Slug normalisieren: nur Kleinbuchstaben, Zahlen, Bindestriche + shortlinkSlug = input.value.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + shortlinkSlugEdited = true; + shortlinkQrDataUrl = ''; // QR zurücksetzen bei Slug-Änderung + } + + async function ensurePublished(): Promise { + if (shortlinkPublished) return true; + if (!shortlinkSlug || isPublishingShortlink) return false; + + isPublishingShortlink = true; try { - qrCodeDataUrl = await QRCode.toDataURL(naddrLink, { - width: 200, - margin: 2, - color: { - dark: '#000000', - light: '#ffffff' - } - }); + const success = await boardStore.publishShortlink(shortlinkSlug); + if (success) { + publishedSlug = shortlinkSlug; + return true; + } else { + toast.error('Kurzlink konnte nicht publiziert werden'); + return false; + } } catch (error) { - console.error('Fehler beim Generieren des QR-Codes:', error); - qrCodeDataUrl = ''; + console.error('Fehler beim Publizieren des Kurzlinks:', error); + toast.error('Fehler beim Publizieren', { + description: error instanceof Error ? error.message : 'Unbekannter Fehler' + }); + return false; + } finally { + isPublishingShortlink = false; } } - // naddr-Link kopieren - async function copyNaddrLink() { + async function copyShortlink() { + const published = await ensurePublished(); + if (!published) return; + try { - await navigator.clipboard.writeText(naddrLink); - naddrCopied = true; - toast.success('Nostr-Link kopiert!', { - description: 'Der naddr-Link wurde in die Zwischenablage kopiert' - }); - setTimeout(() => naddrCopied = false, 2000); + await navigator.clipboard.writeText(shortlinkUrl); + shortlinkCopied = true; + toast.success('Kurzlink kopiert!'); + setTimeout(() => shortlinkCopied = false, 2000); } catch { toast.error('Fehler beim Kopieren'); } } - // Im Browser öffnen (neuer Tab) - function openNaddrInBrowser() { - if (naddrLink) { - window.open(naddrLink, '_blank'); + async function openShortlink() { + const published = await ensurePublished(); + if (!published) return; + window.open(shortlinkUrl, '_blank'); + } + + async function handleQrCode() { + const published = await ensurePublished(); + if (!published) return; + + try { + shortlinkQrDataUrl = await QRCode.toDataURL(shortlinkUrl, { + width: 200, + margin: 2, + color: { dark: '#000000', light: '#ffffff' } + }); + } catch (error) { + console.error('Fehler beim Generieren des QR-Codes:', error); + shortlinkQrDataUrl = ''; } } @@ -557,6 +583,7 @@
+ {#if !showEditorsTab}
- + {/if} {#if showLinkTabs || showEditorsTab} {#if showTabList} {#if showLinkTabs} - Nostr-Link + Kurzlink Share & Fork @@ -591,74 +618,92 @@ {#if showLinkTabs} -
+

- Teile die permanente Nostr-Adresse dieses Boards. Empfänger können das Board - direkt über Nostr-Relays laden und sehen immer die aktuelle Version. + Teile einen kurzen, lesbaren Link zu deinem Board. Kopieren, Öffnen und QR-Code publizieren den Link automatisch.

- - {#if qrCodeDataUrl} -
-
- QR-Code für Nostr-Link + {#if naddrLink} + +
+
+ /b/ +
-
-

- Scanne diesen QR-Code, um das Board auf einem anderen Gerät zu öffnen -

- {/if} - -
- {#if naddrLink} - - {:else} -
- ⚠️ Board muss veröffentlicht sein (nicht Privat) und einen Author haben. -
+ +
+ + + {#if shortlinkSlug} +

+ → {shortlinkUrl} + {#if shortlinkPublished} + ✓ publiziert + {/if} +

{/if} -
- -
-

ℹ️ Was ist ein Nostr-Link (naddr)?

-
    -
  • Permanente Adresse: Das Board ist über diese URL immer erreichbar
  • -
  • Immer aktuell: Empfänger sehen live die neueste Version
  • -
  • Read-only: Besucher können das Board ansehen, aber nicht bearbeiten
  • -
  • Dezentral: Funktioniert über jeden öffentlichen Nostr-Client/Relay
  • -
-
- - {#if naddrLink} -
-

- - Dieser Link ist klein (~80 Bytes) und funktioniert in allen Browsern. + + + {#if shortlinkQrDataUrl} +

+
+ QR-Code für Board-Link +
+
+

+ Scanne diesen QR-Code, um das Board auf einem anderen Gerät zu öffnen

+ {/if} + +
+

ℹ️ Wie funktioniert der Kurzlink?

+
    +
  • Kurz & lesbar: Einfach weiterzugeben und zu merken
  • +
  • Immer aktuell: Empfänger sehen live die neueste Version
  • +
  • Dezentral: Wird als Nostr-Event gespeichert und über Relays aufgelöst
  • +
  • Automatisch: Kopieren, Öffnen oder QR-Code publiziert den Link automatisch
  • +
+
+ {:else} +
+ ⚠️ Board muss veröffentlicht sein (nicht Privat) und einen Author haben.
{/if}
diff --git a/src/lib/stores/kanbanStore.svelte.ts b/src/lib/stores/kanbanStore.svelte.ts index c194ddab..af8015fb 100644 --- a/src/lib/stores/kanbanStore.svelte.ts +++ b/src/lib/stores/kanbanStore.svelte.ts @@ -2549,6 +2549,58 @@ export class BoardStore { return result; } + /** + * Publiziert einen Shortlink (Kind 30491) für das aktuelle Board. + * Der Slug wird als d-Tag gespeichert und mappt auf den vollen naddr. + * + * @param slug - Das gewünschte Kürzel (z.B. "projekt-x") + * @returns true wenn erfolgreich publiziert + */ + public async publishShortlink(slug: string): Promise { + const ndk = this.nostrIntegration?.getNDK(); + if (!ndk) { + console.error('[BoardStore] ❌ NDK nicht verfügbar für Shortlink-Publish'); + return false; + } + + const board = this.board; + if (!board.author) { + console.error('[BoardStore] ❌ Board hat keinen Author — Shortlink kann nicht erstellt werden'); + return false; + } + + try { + const { createShortlinkEvent, createBoardNaddr } = await import('$lib/utils/nostrEvents.js'); + const { settingsStore } = await import('$lib/stores/settingsStore.svelte.js'); + + const relayHints: string[] = settingsStore.settings.relaysPublic || []; + const naddr = createBoardNaddr(board.id, board.author, relayHints); + + const event = createShortlinkEvent( + slug, + naddr, + board.id, + board.author, + board.name, + ndk + ); + + // Auf öffentlichen Relays publizieren (Shortlinks müssen auffindbar sein) + const relays = await event.publish(); + + if (relays.size === 0) { + console.error('[BoardStore] ❌ Shortlink konnte auf keinem Relay publiziert werden'); + return false; + } + + console.log(`[BoardStore] ✅ Shortlink "${slug}" publiziert auf ${relays.size} Relay(s)`); + return true; + } catch (error) { + console.error('[BoardStore] ❌ Fehler beim Publizieren des Shortlinks:', error); + return false; + } + } + public parseShareToken(token: string): any { return ExportImport.parseShareToken(token); } diff --git a/src/lib/utils/nostrEvents.spec.ts b/src/lib/utils/nostrEvents.spec.ts index 0edf4eac..286d82e9 100644 --- a/src/lib/utils/nostrEvents.spec.ts +++ b/src/lib/utils/nostrEvents.spec.ts @@ -8,7 +8,12 @@ import { nostrEventToCard, createBoardNaddr, createBoardNaddrUrl, - decodeBoardNaddr + decodeBoardNaddr, + slugifyBoardName, + createShortlinkEvent, + resolveShortlink, + resolveShortlinkBySlug, + EVENT_KINDS } from './nostrEvents.js'; import { Card } from '../classes/BoardModel.js'; import type NDK from '@nostr-dev-kit/ndk'; @@ -428,3 +433,301 @@ describe('nostrEvents - naddr Link Generation', () => { }); }); }); + + +describe('nostrEvents - Shortlink', () => { + + describe('slugifyBoardName', () => { + it('should convert simple names to lowercase slug', () => { + expect(slugifyBoardName('My Board')).toBe('my-board'); + }); + + it('should handle German umlauts', () => { + expect(slugifyBoardName('Über die Brücke')).toBe('ueber-die-bruecke'); + }); + + it('should handle ß', () => { + expect(slugifyBoardName('Große Straße')).toBe('grosse-strasse'); + }); + + it('should remove diacritical marks', () => { + expect(slugifyBoardName('Café résumé')).toBe('cafe-resume'); + }); + + it('should replace special characters with hyphens', () => { + expect(slugifyBoardName('Board: Test (v2) #1')).toBe('board-test-v2-1'); + }); + + it('should collapse multiple hyphens', () => { + expect(slugifyBoardName('A --- B')).toBe('a-b'); + }); + + it('should remove leading and trailing hyphens', () => { + expect(slugifyBoardName('--hello--')).toBe('hello'); + }); + + it('should truncate to 48 characters', () => { + const longName = 'A'.repeat(100); + expect(slugifyBoardName(longName).length).toBeLessThanOrEqual(48); + }); + + it('should handle empty string', () => { + expect(slugifyBoardName('')).toBe(''); + }); + + it('should handle numbers', () => { + expect(slugifyBoardName('Board 42')).toBe('board-42'); + }); + }); + + describe('createShortlinkEvent', () => { + let mockNdk: NDK; + + beforeEach(() => { + mockNdk = {} as NDK; + }); + + it('should create event with correct kind', () => { + const event = createShortlinkEvent( + 'my-slug', + 'naddr1abc123', + 'board-id', + 'author-pubkey', + 'My Board', + mockNdk + ); + + expect(event.kind).toBe(EVENT_KINDS.SHORTLINK); + }); + + it('should set d-tag to slug', () => { + const event = createShortlinkEvent( + 'projekt-x', + 'naddr1abc123', + 'board-id', + 'author-pubkey', + 'Projekt X', + mockNdk + ); + + const dTag = event.tags.find(t => t[0] === 'd'); + expect(dTag).toBeDefined(); + expect(dTag?.[1]).toBe('projekt-x'); + }); + + it('should set r-tag to naddr', () => { + const naddr = 'naddr1qqs8vxfmpwqq5x7czsfy8'; + const event = createShortlinkEvent( + 'my-slug', + naddr, + 'board-id', + 'author-pubkey', + undefined, + mockNdk + ); + + const rTag = event.tags.find(t => t[0] === 'r'); + expect(rTag).toBeDefined(); + expect(rTag?.[1]).toBe(naddr); + }); + + it('should set a-tag with board address', () => { + const event = createShortlinkEvent( + 'my-slug', + 'naddr1abc', + 'board-123', + 'pubkey-abc', + 'Title', + mockNdk + ); + + const aTag = event.tags.find(t => t[0] === 'a'); + expect(aTag).toBeDefined(); + expect(aTag?.[1]).toBe(`${EVENT_KINDS.BOARD}:pubkey-abc:board-123`); + }); + + it('should include title tag when provided', () => { + const event = createShortlinkEvent( + 'my-slug', + 'naddr1abc', + 'board-id', + 'author', + 'Mein Board Titel', + mockNdk + ); + + const titleTag = event.tags.find(t => t[0] === 'title'); + expect(titleTag).toBeDefined(); + expect(titleTag?.[1]).toBe('Mein Board Titel'); + }); + + it('should not include title tag when undefined', () => { + const event = createShortlinkEvent( + 'my-slug', + 'naddr1abc', + 'board-id', + 'author', + undefined, + mockNdk + ); + + const titleTag = event.tags.find(t => t[0] === 'title'); + expect(titleTag).toBeUndefined(); + }); + + it('should set content to naddr', () => { + const naddr = 'naddr1qqs8vxfmpwqq5x7czsfy8'; + const event = createShortlinkEvent( + 'my-slug', + naddr, + 'board-id', + 'author', + undefined, + mockNdk + ); + + expect(event.content).toBe(naddr); + }); + }); + + describe('resolveShortlink', () => { + let mockNdk: NDK; + + it('should resolve slug via r-tag', async () => { + const expectedNaddr = 'naddr1abc123resolved'; + mockNdk = { + fetchEvent: vi.fn().mockResolvedValue({ + tags: [['d', 'my-slug'], ['r', expectedNaddr]], + content: 'fallback-content', + }) + } as unknown as NDK; + + const result = await resolveShortlink('my-slug', 'author-pub', mockNdk); + expect(result).toBe(expectedNaddr); + expect(mockNdk.fetchEvent).toHaveBeenCalledWith({ + kinds: [EVENT_KINDS.SHORTLINK], + authors: ['author-pub'], + '#d': ['my-slug'] + }); + }); + + it('should fallback to content when no r-tag', async () => { + mockNdk = { + fetchEvent: vi.fn().mockResolvedValue({ + tags: [['d', 'my-slug']], + content: 'naddr1fallback', + }) + } as unknown as NDK; + + const result = await resolveShortlink('my-slug', 'author-pub', mockNdk); + expect(result).toBe('naddr1fallback'); + }); + + it('should return null when event not found', async () => { + mockNdk = { + fetchEvent: vi.fn().mockResolvedValue(null) + } as unknown as NDK; + + const result = await resolveShortlink('nonexistent', 'author-pub', mockNdk); + expect(result).toBeNull(); + }); + + it('should return null when event has no r-tag and empty content', async () => { + mockNdk = { + fetchEvent: vi.fn().mockResolvedValue({ + tags: [['d', 'my-slug']], + content: '', + }) + } as unknown as NDK; + + const result = await resolveShortlink('my-slug', 'author-pub', mockNdk); + expect(result).toBeNull(); + }); + }); + + describe('resolveShortlinkBySlug', () => { + let mockNdk: NDK; + + it('should resolve slug without author', async () => { + const expectedNaddr = 'naddr1resolved-no-author'; + const mockEvents = new Set([ + { + tags: [['d', 'test-slug'], ['r', expectedNaddr]], + content: expectedNaddr, + pubkey: 'pubkey-abc', + created_at: 1000, + } + ]); + mockNdk = { + fetchEvents: vi.fn().mockResolvedValue(mockEvents) + } as unknown as NDK; + + const result = await resolveShortlinkBySlug('test-slug', mockNdk); + expect(result).toEqual({ naddr: expectedNaddr, authorPubkey: 'pubkey-abc' }); + }); + + it('should return null when no events found', async () => { + mockNdk = { + fetchEvents: vi.fn().mockResolvedValue(new Set()) + } as unknown as NDK; + + const result = await resolveShortlinkBySlug('nonexistent', mockNdk); + expect(result).toBeNull(); + }); + + it('should pick latest event (Last-Write-Wins)', async () => { + const mockEvents = new Set([ + { + tags: [['d', 'slug'], ['r', 'naddr-old']], + content: 'naddr-old', + pubkey: 'pubkey-old', + created_at: 1000, + }, + { + tags: [['d', 'slug'], ['r', 'naddr-newest']], + content: 'naddr-newest', + pubkey: 'pubkey-new', + created_at: 3000, + }, + { + tags: [['d', 'slug'], ['r', 'naddr-mid']], + content: 'naddr-mid', + pubkey: 'pubkey-mid', + created_at: 2000, + }, + ]); + mockNdk = { + fetchEvents: vi.fn().mockResolvedValue(mockEvents) + } as unknown as NDK; + + const result = await resolveShortlinkBySlug('slug', mockNdk); + expect(result).toEqual({ naddr: 'naddr-newest', authorPubkey: 'pubkey-new' }); + }); + + it('should fallback to content when no r-tag', async () => { + const mockEvents = new Set([ + { + tags: [['d', 'slug']], + content: 'naddr-from-content', + pubkey: 'pubkey-abc', + created_at: 1000, + } + ]); + mockNdk = { + fetchEvents: vi.fn().mockResolvedValue(mockEvents) + } as unknown as NDK; + + const result = await resolveShortlinkBySlug('slug', mockNdk); + expect(result).toEqual({ naddr: 'naddr-from-content', authorPubkey: 'pubkey-abc' }); + }); + + it('should return null when fetchEvents returns null', async () => { + mockNdk = { + fetchEvents: vi.fn().mockResolvedValue(null) + } as unknown as NDK; + + const result = await resolveShortlinkBySlug('slug', mockNdk); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/lib/utils/nostrEvents.ts b/src/lib/utils/nostrEvents.ts index 3ea194d2..5e3762f6 100644 --- a/src/lib/utils/nostrEvents.ts +++ b/src/lib/utils/nostrEvents.ts @@ -29,6 +29,7 @@ export const EVENT_KINDS = { CARD: 30302, // Card definition (replaceable) SNAPSHOT: 30303, // Board snapshot/version (non-replaceable) - Phase 1.5 COLUMN_ORDER_PATCH: 8571, // Column order patch (regular) - allows editors to sync column order without 30301 forks + SHORTLINK: 30491, // URL shortener redirect (addressable) - maps slug → naddr COMMENT: 1, // Text note (regular) DELETION: 5, // Event deletion (NIP-09) SOFT_LOCK: 20001, // "Now editing" soft lock (ephemeral) @@ -854,4 +855,138 @@ export function createBoardShareUrl( return `${baseUrl.replace(/\/$/, '')}${naddrPath}`; } +// ============================================================================ +// SHORTLINK (Kind 30491) — URL-Shortener via Addressable Events +// ============================================================================ + +/** + * Generiert einen URL-freundlichen Slug aus einem Board-Namen. + * + * @param boardName - Der Name des Boards + * @returns Slug (z.B. "mein-tolles-board") + */ +export function slugifyBoardName(boardName: string): string { + return boardName + .toLowerCase() + .replace(/[äÄ]/g, 'ae') + .replace(/[öÖ]/g, 'oe') + .replace(/[üÜ]/g, 'ue') + .replace(/ß/g, 'ss') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // Diakritische Zeichen entfernen + .replace(/[^a-z0-9]+/g, '-') // Nicht-alphanumerische Zeichen → Bindestrich + .replace(/^-+|-+$/g, '') // Führende/tailing Bindestriche entfernen + .slice(0, 48); // Max 48 Zeichen +} + +/** + * Erstellt ein Shortlink-Event (Kind 30491, Addressable). + * + * Das Event mappt einen kurzen Slug (d-Tag) auf den vollen naddr-String. + * Relay-Clients können das Event über `["d", slug]` finden. + * + * Tags: + * - d: Der Slug/Kürzel (z.B. "projekt-x") + * - r: Der volle naddr-String (für maschinelles Lesen) + * - a: Board-Adresse "30301::" (für Querverweise) + * - title: Board-Titel (für Discovery) + * + * @param slug - Das gewünschte Kürzel + * @param naddr - Der volle naddr-String des Boards + * @param boardId - Die Board-ID (d-tag des Boards) + * @param authorPubkey - Pubkey des Board-Autors + * @param boardTitle - Optionaler Board-Titel + * @param ndk - NDK-Instanz + * @returns NDKEvent (noch nicht signiert/publiziert) + */ +export function createShortlinkEvent( + slug: string, + naddr: string, + boardId: string, + authorPubkey: string, + boardTitle: string | undefined, + ndk: NDK +): NDKEvent { + const event = new NDKEvent(ndk); + event.kind = EVENT_KINDS.SHORTLINK; + event.tags = [ + ['d', slug], + ['r', naddr], + ['a', `${EVENT_KINDS.BOARD}:${authorPubkey}:${boardId}`], + ]; + + if (boardTitle) { + event.tags.push(['title', boardTitle]); + } + + event.content = naddr; // Content = naddr (human-readable fallback) + + return event; +} + +/** + * Löst einen Shortlink-Slug auf, indem das Shortlink-Event (Kind 30491) + * von Nostr abgefragt wird. + * + * @param slug - Das Kürzel (z.B. "projekt-x") + * @param authorPubkey - Pubkey des Shortlink-Erstellers + * @param ndk - NDK-Instanz + * @returns Der naddr-String oder null + */ +export async function resolveShortlink( + slug: string, + authorPubkey: string, + ndk: NDK +): Promise { + const filter = { + kinds: [EVENT_KINDS.SHORTLINK], + authors: [authorPubkey], + '#d': [slug] + }; + + const event = await ndk.fetchEvent(filter as any); + + if (!event) return null; + + // Bevorzuge r-Tag, Fallback auf content + const rTag = event.tags.find(t => t[0] === 'r')?.[1]; + return rTag || event.content || null; +} + +/** + * Löst einen Shortlink-Slug auf — ohne vorher den Author zu kennen. + * Sucht über ALLE Autoren (langsamer, braucht unterstützendes Relay). + * + * @param slug - Das Kürzel + * @param ndk - NDK-Instanz + * @returns Der naddr-String oder null + */ +export async function resolveShortlinkBySlug( + slug: string, + ndk: NDK +): Promise<{ naddr: string; authorPubkey: string } | null> { + const filter = { + kinds: [EVENT_KINDS.SHORTLINK], + '#d': [slug] + }; + + const events = await ndk.fetchEvents(filter as any); + if (!events || events.size === 0) return null; + + // Nimm das neueste Event (Last-Write-Wins) + let latest: NDKEvent | null = null; + for (const ev of events) { + if (!latest || (ev.created_at ?? 0) > (latest.created_at ?? 0)) { + latest = ev; + } + } + + if (!latest) return null; + + const rTag = latest.tags.find(t => t[0] === 'r')?.[1]; + const naddr = rTag || latest.content || null; + + return naddr ? { naddr, authorPubkey: latest.pubkey } : null; +} + export type { BoardProps, CardProps, ColumnProps }; diff --git a/src/routes/b/[slug]/+page.svelte b/src/routes/b/[slug]/+page.svelte new file mode 100644 index 00000000..89e5e1c5 --- /dev/null +++ b/src/routes/b/[slug]/+page.svelte @@ -0,0 +1,136 @@ + + +{#if status === 'loading'} +
+
+
+ +

Kurzlink wird aufgelöst...

+

{loadingStep}

+ {#if resolvedSlug} +
+ /b/{resolvedSlug} +
+ {/if} +
+
+
+{:else if status === 'not-found'} +
+
+
+ +

Kurzlink nicht gefunden

+

+ Der Kurzlink /b/{resolvedSlug} + konnte auf keinem Nostr-Relay gefunden werden. +

+

+ Mögliche Ursachen: Der Link wurde gelöscht, ist noch nicht publiziert + oder die Relays sind nicht erreichbar. +

+ + Zurück zur Übersicht + +
+
+
+{:else if status === 'error'} +
+
+
+ +

Fehler

+

{errorMessage}

+ + Zurück zur Übersicht + +
+
+
+{/if} diff --git a/src/routes/b/[slug]/+page.ts b/src/routes/b/[slug]/+page.ts new file mode 100644 index 00000000..a2cfb30a --- /dev/null +++ b/src/routes/b/[slug]/+page.ts @@ -0,0 +1,13 @@ +// src/routes/b/[slug]/+page.ts +// Server-side parameter extraction for shortlink-based board URLs + +import type { PageLoad } from './$types'; + +export const load: PageLoad = ({ params }) => { + return { + slug: params.slug + }; +}; + +// Disable prerendering for dynamic routes +export const prerender = false;