diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cefeb023..064ccb31 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,8 +2,8 @@ **Projekt:** Nostr-basiertes KI-Kanban-Board mit Svelte 5 **Repository:** edufeed-org/kanban-editor -**Letztes Update:** 29. Oktober 2025 -**Status:** Phase 1 (In Entwicklung) +**Letztes Update:** 3. Dezember 2025 +**Status:** Phase 1 ✅ COMPLETE (inkl. Board Snapshots) **Governance:** 🔴 v3.0 ACTIVE - Code ↔ Docs Sync MANDATORY --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 41707ebc..9420887e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## Version 4.7.0 - Board Snapshots / Versionshistorie 📸 + +**Datum:** 3. Dezember 2025 +**Branch:** `main` +**Status:** ✅ Vollständig implementiert + +### ✨ Neues Feature: Board Versioning + +Benutzer können jetzt **manuelle Snapshots** ihrer Kanban-Boards erstellen und bei Bedarf zu früheren Versionen zurückkehren. + +#### Features +- **Manuelles Speichern von Versionen** - Button "Versionen" in der Topbar +- **Versionshistorie anzeigen** - Liste aller Snapshots mit Metadaten +- **Wiederherstellen** - Zurückkehren zu einem früheren Board-Zustand +- **Automatisches Backup** vor jeder Wiederherstellung + +#### Technische Details +- Snapshots werden als **Kind 30303 Nostr Events** gespeichert (non-replaceable) +- Speicherung auf privaten Relays (für Draft-Boards) oder öffentlichen Relays +- Event-Tags: `a` (Board-Referenz), `v` (Label), `r` (Grund), `t` (Timestamp) +- Vollständiges Board-JSON im Event-Content + +#### Komponenten +- `VersionHistory.svelte` - Dialog-Komponente für Versionshistorie +- `NostrIntegration.publishSnapshot()` - Event-Publishing +- `NostrIntegration.loadSnapshots()` - Laden von Snapshots von Relays +- `BoardStore.createManualSnapshot()` / `rollbackToSnapshot()` - Store-API + +#### Relay-Konfiguration +- Kind 30303 zur Relay-Allowlist hinzugefügt (`docker-relay-config.toml`) +- Explizites Laden von privaten Relays für Snapshots + +### 📚 Dokumentation +- `docs/FEATURE/BOARD-SNAPSHOTS.md` - Vollständige Feature-Dokumentation +- ROADMAP.md aktualisiert (Meilenstein 1.5C: DONE) + +### 🔧 Technische Fixes +- TypeScript-Fehler in `nostr.ts` und `syncManager.svelte.ts` behoben +- Relay-Pool-Handling verbessert (keine `addRelay(url)` mehr, da Relays bereits im Pool) + +--- + ## Unreleased - Board-Sharing Realtime Anzeige 🚀 **Datum:** 24. November 2025 diff --git a/docker-relay-config.toml b/docker-relay-config.toml index e044b4a0..ca9c3e4a 100644 --- a/docker-relay-config.toml +++ b/docker-relay-config.toml @@ -21,5 +21,7 @@ level = "info" # Limits [limits] # Explicitly allow these event kinds -event_kind_allowlist = [0, 1, 5, 30000, 30301, 30302, 20001] +# 0: Metadata, 1: Text Note, 5: Event Deletion, 30000: Categorized People List +# 30301: Kanban Board, 30302: Kanban Card, 30303: Kanban Snapshot, 20001: Ephemeral Soft Lock +event_kind_allowlist = [0, 1, 5, 30000, 30301, 30302, 30303, 20001] diff --git a/docs/COLLABORATION/ROADMAP.md b/docs/COLLABORATION/ROADMAP.md index 90f42d47..981dbd58 100644 --- a/docs/COLLABORATION/ROADMAP.md +++ b/docs/COLLABORATION/ROADMAP.md @@ -1,18 +1,16 @@ # 🗺️ Roadmap: Nostr-basiertes KI-Kanban-Board -**Version:** 3.3 (PHASE 4 INFRASTRUCTURE ANALYSIS - 13. November 2025) -**Aktualisiert:** 13. November 2025 (Phase 4 Infrastruktur-Analyse) -**Status:** ✅ **PHASE 1: 95%** | ✅ **PHASE 3: 90%** | 🟡 **Phase 2: 15%** | 🟡 **Phase 4: 85% Infrastructure** +**Version:** 3.4 (PHASE 1.5C COMPLETE - 3. Dezember 2025) +**Aktualisiert:** 3. Dezember 2025 (Board Snapshots Feature fertig) +**Status:** ✅ **PHASE 1: 100% COMPLETE** | ✅ **PHASE 3: 90%** | 🟡 **Phase 2: 15%** | 🟡 **Phase 4: 85% Infrastructure** **Projekt-Ziel:** Vollständige Implementierung bis 31.12.2025, Testing ab 01.01.2026 -**Status:** ✅ **PHASE 1: COMPLETE** | ✅ **PHASE 3: 90%** | 🟡 **Phase 2: 15%** | 🟡 **Phase 4: 85% Infrastructure** -**Projekt-Ziel:** Vollständige Implementierung bis 31.12.2025, Testing ab 01.01.2026 - -**🆕 PHASE 1 COMPLETION (28.12.2024):** -- ✅ **Phase 1 Status:** **100% COMPLETE** (alle Meilensteine 1.0-1.6 DONE!) +**🆕 PHASE 1 COMPLETE (3.12.2025):** +- ✅ **Phase 1 Status:** **100% COMPLETE** (alle Meilensteine 1.0-1.6 + 1.5C DONE!) - ✅ **Phase 3 Status:** **90% Complete** (ChatStore, AIPanel, LLM ALL DONE, 3 AI Actions fehlen!) - 🟡 **Phase 2 Status:** **15% Complete** (Settings+Dark Mode DONE, Mobile+A11y offen) - 🟡 **Phase 4 Status:** **85% Infrastructure Ready** (SoftLockManager, MergeEngine, SyncManager ✅! Nur UI fehlt) +- ✅ **MEILENSTEIN 1.5C COMPLETE:** Board Snapshots / Versionshistorie Feature FERTIG! - ✅ **MEILENSTEIN 1.6 COMPLETE:** Demo Board System für anonyme User + benutzerbasierte Filterung FERTIG! **🆕 Neu in v3.2 (Demo Board System - 28.12.2024):** diff --git a/docs/FEATURE/BOARD-SNAPSHOTS.md b/docs/FEATURE/BOARD-SNAPSHOTS.md new file mode 100644 index 00000000..04e183cd --- /dev/null +++ b/docs/FEATURE/BOARD-SNAPSHOTS.md @@ -0,0 +1,211 @@ +# Board Versioning / Snapshots Feature + +**Status:** ✅ Vollständig Implementiert (Phase 1.5C) +**Erstellt:** 28. Dezember 2024 +**Aktualisiert:** 3. Dezember 2025 +**Basiert auf:** `docs/PROPOSALS/BOARD-VERSIONING.md` + +--- + +## 📋 Übersicht + +Das Board Versioning Feature ermöglicht es Benutzern, **manuelle Snapshots** ihrer Kanban-Boards zu erstellen und bei Bedarf zu früheren Versionen zurückzukehren. Snapshots werden als **Kind 30303 Nostr Events** gespeichert (non-replaceable). + +--- + +## ✨ Features + +### 1. Manuelles Speichern von Versionen +- Button "Versionen" in der Topbar öffnet den VersionHistory-Dialog +- User kann ein Label/eine Beschreibung eingeben (z.B. "Vor großem Umbau") +- Snapshot wird als Kind 30303 Event auf Nostr Relays veröffentlicht + +### 2. Versionshistorie anzeigen +- Liste aller gespeicherten Snapshots sortiert nach Datum (neueste zuerst) +- Anzeige von: + - Label/Beschreibung + - Zeitstempel (relativ und absolut) + - Anzahl der Spalten und Karten + - Erstellungsgrund (manuell, automatisch, vor Import, Backup) + +### 3. Wiederherstellen einer Version +- "Wiederherstellen" Button pro Snapshot +- Bestätigungs-Dialog mit Warnung +- **Automatisches Backup** des aktuellen Zustands vor der Wiederherstellung +- Board wird vollständig auf den Snapshot-Zustand zurückgesetzt + +--- + +## 🏗️ Technische Architektur + +### Event-Struktur (Kind 30303) + +```javascript +{ + kind: 30303, // Non-replaceable - jeder Snapshot ist permanent + tags: [ + ["a", "30301::"], // Referenz zum Board + ["v", "User-Label"], // Benutzer-Beschreibung + ["r", "manual"], // Grund (manual|auto_save|before_import|before_restore) + ["t", "1735392000"] // Unix-Timestamp + ], + content: "{...komplettes Board-JSON...}" // Board.getContextData(true) +} +``` + +### Komponenten-Übersicht + +``` +src/ +├── lib/ +│ ├── stores/ +│ │ ├── kanbanStore.svelte.ts ← Snapshot-Methoden hinzugefügt +│ │ │ ├── createManualSnapshot() +│ │ │ ├── loadSnapshots() +│ │ │ ├── rollbackToSnapshot() +│ │ │ └── createAutoSnapshot() +│ │ │ +│ │ └── boardstore/ +│ │ └── nostr.ts ← NostrIntegration erweitert +│ │ ├── publishSnapshot() +│ │ ├── loadSnapshots() +│ │ ├── fetchSnapshotByLabel() +│ │ └── fetchSnapshotById() +│ │ +│ ├── components/ +│ │ └── board/ +│ │ ├── index.ts ← Export hinzugefügt +│ │ └── VersionHistory.svelte ← NEU: Dialog-Komponente +│ │ +│ └── utils/ +│ └── nostrEvents.ts ← EVENT_KINDS.SNAPSHOT (30303) +│ +└── routes/ + └── cardsboard/ + ├── Topbar.svelte ← VersionHistory importiert + └── types.ts ← Snapshot-Typen definiert +``` + +--- + +## 📖 API-Referenz + +### BoardStore Methoden + +#### `createManualSnapshot(label: string): Promise` +Erstellt einen manuellen Snapshot des aktuellen Board-Zustands. + +```typescript +await boardStore.createManualSnapshot('Vor großem Umbau'); +``` + +#### `loadSnapshots(): Promise` +Lädt alle Snapshots für das aktuelle Board von Nostr Relays. + +```typescript +const snapshots = await boardStore.loadSnapshots(); +// Returns: Array<{ id, label, timestamp, reason, cardCount, columnCount, createdBy, boardData }> +``` + +#### `rollbackToSnapshot(snapshotId: string): Promise` +Stellt das Board auf einen früheren Snapshot wieder her. + +```typescript +// Erstellt automatisch ein Backup vor der Wiederherstellung! +await boardStore.rollbackToSnapshot('snapshot-event-id'); +``` + +#### `createAutoSnapshot(reason: 'before_import' | 'auto_save'): Promise` +Erstellt einen automatischen Snapshot (z.B. vor Import-Operationen). + +```typescript +await boardStore.createAutoSnapshot('before_import'); +``` + +--- + +## 🎨 UI-Komponente + +### VersionHistory.svelte + +Die Komponente besteht aus: + +1. **Trigger-Button** (in Topbar) + - Icon: History + - Label: "Versionen" + +2. **Dialog** + - Header: "Versionshistorie" + - Input für neues Snapshot-Label + - "Speichern" Button + +3. **Snapshot-Liste** + - Karten pro Snapshot mit: + - Label (fett) + - Badge für Grund (Manuell, Auto, Backup, Vor Import) + - Metadaten (Datum, Spalten, Karten, Ersteller) + - "Wiederherstellen" Button + - Bestätigungs-Dialog für Wiederherstellung + +--- + +## 📝 Verwendungsbeispiele + +### Beispiel 1: Vor größeren Änderungen sichern + +```typescript +// Im Code (z.B. vor Import) +await boardStore.createManualSnapshot('Backup vor großem Import'); + +// Dann Import durchführen... +await boardStore.importBoardFromJson(jsonData, 'merge'); +``` + +### Beispiel 2: Snapshot per UI erstellen + +1. Klicke auf "Versionen" in der Topbar +2. Gib ein Label ein (z.B. "Version 1.0 - Fertig für Review") +3. Klicke "Speichern" +4. Toast zeigt Bestätigung + +### Beispiel 3: Zu einem früheren Stand zurückkehren + +1. Klicke auf "Versionen" in der Topbar +2. Finde den gewünschten Snapshot in der Liste +3. Klicke "Wiederherstellen" +4. Bestätige die Warnung +5. Board wird zurückgesetzt, Toast zeigt Bestätigung + +--- + +## 🔐 Sicherheit & Datenschutz + +- Snapshots werden auf denselben Relays gespeichert wie das Board +- Die publishState des Boards (draft/published/archived) wird für Relay-Auswahl verwendet +- Automatische Backups vor Wiederherstellung verhindern Datenverlust +- Snapshot-Content ist das komplette Board-JSON (inkl. aller Karten und Metadaten) + +--- + +## 🚀 Zukünftige Erweiterungen (Phase 2+) + +- [ ] Auto-Save Snapshots bei bestimmten Trigger-Events +- [ ] Snapshot-Diff Ansicht (was hat sich geändert?) +- [ ] Snapshot-Suche nach Label +- [ ] Export einzelner Snapshots als JSON +- [ ] Benachrichtigungen bei Snapshot-Erstellung durch andere Maintainer + +--- + +## 📚 Verwandte Dokumentation + +- [Board Versioning Proposal](./PROPOSALS/BOARD-VERSIONING.md) - Ursprünglicher Design-Vorschlag +- [Export/Import Feature](./FEATURE/IMPORT-EXPORT.md) - Board-Export/Import +- [Merge System](./FEATURE/MERGE-SYSTEM.md) - Konfliktauflösung +- [ROADMAP](./COLLABORATION/ROADMAP.md) - Projekt-Roadmap + +--- + +**Implementiert am:** 28. Dezember 2024 +**Getestet mit:** svelte-check (0 Fehler) +**Basiert auf:** Phase 1.5 BOARD-VERSIONING.md Proposal diff --git a/docs/_INDEX.md b/docs/_INDEX.md index d6aa8d9c..90f458c2 100644 --- a/docs/_INDEX.md +++ b/docs/_INDEX.md @@ -426,6 +426,7 @@ docs/ | [`MERGE-SYSTEM.md`](./FEATURE/MERGE-SYSTEM.md) | Phase 1.5 - Git-like 3-way Merge + Visual Test Route | ✅ Neu (26.10.) | | [`SHARELINK.md`](./FEATURE/SHARELINK.md) | Phase 1.5 - URL-basiertes Board-Sharing mit Token-Encoding | ✅ Neu (31.10.) | | [`IMPORT-EXPORT.md`](./FEATURE/IMPORT-EXPORT.md) | Phase 1.5 - JSON-Export/Import mit 3 Modi (merge/new/overwrite) | ✅ Neu (31.10.) | +| [`BOARD-SNAPSHOTS.md`](./FEATURE/BOARD-SNAPSHOTS.md) | 🆕 **NEU (28.12.)**: Phase 1.5 - Board Versioning mit Kind 30303 Snapshots | ✅ Neu (28.12.) | | [`CARD-DESIGN.md`](./FEATURE/CARD-DESIGN.md) | 🆕 **NEU (06.11.)**: UI/UX Design für Card-Komponente mit Badges & Popover | ✅ Neu (06.11.) | | [`RELAY-SELECTION-IMPLEMENTATION.md`](./FEATURE/RELAY-SELECTION-IMPLEMENTATION.md) | ✅ Relay Selection Implementation Summary (referenziert von Test Guide) | ✅ | diff --git a/src/lib/components/board/VersionHistory.svelte b/src/lib/components/board/VersionHistory.svelte new file mode 100644 index 00000000..91c3c8ae --- /dev/null +++ b/src/lib/components/board/VersionHistory.svelte @@ -0,0 +1,382 @@ + + + + + + + + + + + + Versionshistorie + + + Speichere Versionen deines Boards und stelle frühere Zustände wieder her. + + + +
+ +
+
+ + Neue Version speichern +
+ + + {#if !boardStore.ndkReady} +
+ + Nostr-Verbindung wird hergestellt... +
+ {/if} + +
+ e.key === 'Enter' && createSnapshot()} + /> + +
+
+ + + + +
+
+

Gespeicherte Versionen

+ +
+ + {#if isLoading} +
+ + Lade Versionen... +
+ {:else if snapshots.length === 0} +
+ +

Noch keine Versionen gespeichert.

+

Erstelle oben eine neue Version.

+
+ {:else} +
+ {#each snapshots as snapshot (snapshot.id)} + {@const badge = getReasonBadge(snapshot.reason)} +
+
+
+
+ {snapshot.label} + + {badge.text} + +
+ +
+ + + {formatRelativeTime(snapshot.timestamp)} + + + + {snapshot.columnCount} Spalten + + + + {snapshot.cardCount} Karten + + + + {truncatePubkey(snapshot.createdBy)} + +
+
+ +
+ {#if confirmRestoreId === snapshot.id} + +
+ + +
+ {:else} + + {/if} +
+
+ + {#if confirmRestoreId === snapshot.id} +
+ +
+ Achtung: Diese Aktion erstellt automatisch ein Backup + des aktuellen Zustands und ersetzt dann alle Daten mit dieser Version. +
+
+ {/if} +
+ {/each} +
+ {/if} +
+
+ + + + +
+
diff --git a/src/lib/components/board/index.ts b/src/lib/components/board/index.ts index e332b034..33b23c36 100644 --- a/src/lib/components/board/index.ts +++ b/src/lib/components/board/index.ts @@ -1,2 +1,3 @@ export { default as ShareDialog } from './ShareDialog.svelte'; export { default as ShareButton } from './ShareButton.svelte'; +export { default as VersionHistory } from './VersionHistory.svelte'; diff --git a/src/lib/stores/boardstore/nostr.ts b/src/lib/stores/boardstore/nostr.ts index 99bc5c8d..3afcd203 100644 --- a/src/lib/stores/boardstore/nostr.ts +++ b/src/lib/stores/boardstore/nostr.ts @@ -3,7 +3,7 @@ import type { Board, Card, Comment } from '../../classes/BoardModel.js'; import type { BoardProps, ColumnProps } from '../../classes/BoardModel.js'; -import { boardToNostrEvent, cardToNostrEvent, createCommentEvent, createDeletionEvent } from '../../utils/nostrEvents.js'; +import { boardToNostrEvent, cardToNostrEvent, createCommentEvent, createDeletionEvent, EVENT_KINDS } from '../../utils/nostrEvents.js'; import { generateDTag } from '../../utils/idGenerator.js'; import { getTargetRelays } from '../../utils/relaySelection.js'; import { getSyncManager } from '../syncManager.svelte.js'; @@ -11,6 +11,7 @@ import { settingsStore } from '../settingsStore.svelte.js'; import { authStore } from '../authStore.svelte.js'; import { BoardStorage } from './storage.js'; import type NDK from '@nostr-dev-kit/ndk'; +import { NDKRelaySet } from '@nostr-dev-kit/ndk'; import { toast } from 'svelte-sonner'; export class NostrIntegration { @@ -1895,6 +1896,346 @@ export class NostrIntegration { } } + // ============================================================================ + // BOARD SNAPSHOTS (Kind 30303) - Phase 1.5 Board Versioning + // ============================================================================ + + /** + * 🔖 Creates and publishes a manual snapshot of the current board state + * + * Snapshot events (Kind 30303) are NON-REPLACEABLE, meaning each snapshot + * is a permanent record that can be referenced later for rollback. + * + * @param board - The current board instance to snapshot + * @param label - User-provided label/description for this version + * @param reason - Why this snapshot was created (default: 'manual') + * @returns The event ID of the published snapshot, or null if failed + * + * Event Structure: + * ``` + * { + * kind: 30303, + * tags: [ + * ["a", "30301:pubkey:board-id"], // Reference to board + * ["v", "label"], // User label + * ["r", "manual"], // Reason + * ["t", "1699123456"] // Timestamp + * ], + * content: "{...board JSON...}" + * } + * ``` + */ + public async publishSnapshot( + board: Board, + label: string, + reason: 'manual' | 'auto_save' | 'before_import' | 'before_restore' = 'manual' + ): Promise { + if (!this.ndk) { + console.error('[NostrIntegration] ❌ NDK not initialized for snapshot'); + return null; + } + + try { + const { NDKEvent } = await import('@nostr-dev-kit/ndk'); + const snapshotEvent = new NDKEvent(this.ndk); + + // Kind 30303 is non-replaceable - each snapshot is a unique record + snapshotEvent.kind = 30303; + + // Get board author (canonical owner) + const boardAuthor = board.author || authStore.getPubkey() || ''; + const timestamp = Math.floor(Date.now() / 1000); + + // Build tags according to BOARD-VERSIONING.md spec + snapshotEvent.tags = [ + ['a', `30301:${boardAuthor}:${board.id}`], // Reference to board + ['v', label], // User label/description + ['r', reason], // Snapshot reason + ['t', timestamp.toString()], // Unix timestamp + ]; + + // Content is the complete board JSON + const boardData = board.getContextData(true); // full = true for all details + snapshotEvent.content = JSON.stringify(boardData); + + // 📌 SNAPSHOTS always go to private relay (they are personal backups) + // This ensures snapshots are stored even for draft/unpublished boards + const privateRelays = settingsStore.settings.relaysPrivate || []; + + // Fallback: If no private relays configured, use default local relay (matching config.json) + const targetRelays = privateRelays.length > 0 + ? privateRelays + : ['ws://localhost:7000']; + + console.log(`[NostrIntegration] 📡 Snapshot target relays:`, targetRelays); + + // Publish via SyncManager + const syncManager = getSyncManager(); + await syncManager.publishOrQueue( + snapshotEvent, + 'board', // Use board type for sync priority + 'normal', + 'private', // Always use 'private' for snapshots + targetRelays + ); + + console.log(`✅ [NostrIntegration] Snapshot "${label}" published for board ${board.id}`); + console.log(` 📊 Cards: ${boardData.columns?.reduce((sum: number, col: any) => sum + (col.cards?.length || 0), 0) || 0}`); + console.log(` 📁 Columns: ${boardData.columns?.length || 0}`); + + // Return a pseudo-ID (actual ID is assigned by relay after signing) + // We use timestamp + board.id as temporary identifier + return `snapshot-${board.id}-${timestamp}`; + + } catch (error) { + console.error('[NostrIntegration] ❌ Failed to publish snapshot:', error); + toast.error('Snapshot konnte nicht gespeichert werden'); + return null; + } + } + + /** + * 🔍 Loads all snapshots for a specific board from Nostr relays + * + * @param boardId - The board's d-tag ID + * @param boardAuthor - The board owner's pubkey + * @returns Array of BoardSnapshot objects sorted by timestamp (newest first) + */ + public async loadSnapshots( + boardId: string, + boardAuthor: string + ): Promise> { + if (!this.ndk) { + console.error('[NostrIntegration] ❌ NDK not initialized for loading snapshots'); + return []; + } + + try { + // Build filter for Kind 30303 events referencing this board + const aTagValue = `30301:${boardAuthor}:${boardId}`; + + const filter = { + kinds: [EVENT_KINDS.SNAPSHOT], + '#a': [aTagValue], + }; + + console.log(`🔍 [NostrIntegration] Loading snapshots for board ${boardId}...`); + console.log(`🔍 [NostrIntegration] Filter:`, filter); + + // Get private relays to query - snapshots are stored on private relays + const privateRelays = settingsStore.settings.relaysPrivate || []; + const targetRelays = privateRelays.length > 0 + ? privateRelays + : ['ws://localhost:7000']; + + console.log(`🔍 [NostrIntegration] Querying relays:`, targetRelays); + + // Build relay set from connected relays + // Note: Private relays should already be in the NDK pool (added in +layout.svelte) + const connectedRelays = new Set(); + for (const url of targetRelays) { + try { + const relay = this.ndk.pool.getRelay(url); + if (relay) { + // Wait for connection if not connected + if (relay.status !== 1) { // 1 = CONNECTED + console.log(`🔍 [NostrIntegration] Waiting for relay connection: ${url}`); + await relay.connect(); + } + connectedRelays.add(relay); + } else { + console.warn(`🔍 [NostrIntegration] Relay not in pool: ${url} - was it added in +layout.svelte?`); + } + } catch (relayError) { + console.warn(`🔍 [NostrIntegration] Failed to connect relay ${url}:`, relayError); + } + } + + if (connectedRelays.size === 0) { + console.warn('🔍 [NostrIntegration] No relays connected, trying default fetch'); + const events = await this.ndk.fetchEvents(filter as any); + console.log(`🔍 [NostrIntegration] Found ${events.size} snapshot event(s) from default relays`); + return this.parseSnapshotEvents(events); + } + + console.log(`🔍 [NostrIntegration] ${connectedRelays.size}/${targetRelays.length} relays connected`); + const relaySet = new NDKRelaySet(connectedRelays, this.ndk); + + // Fetch events from private relays specifically + const events = await this.ndk.fetchEvents(filter as any, { relaySet }); + + console.log(`🔍 [NostrIntegration] Found ${events.size} snapshot event(s)`); + + return this.parseSnapshotEvents(events); + + } catch (error) { + console.error('[NostrIntegration] ❌ Failed to load snapshots:', error); + return []; + } + } + + /** + * Helper to parse snapshot events into structured data + */ + private parseSnapshotEvents(events: Set): Array<{ + id: string; + label: string; + timestamp: number; + reason: string; + cardCount: number; + columnCount: number; + createdBy: string; + boardData: any; + }> { + const snapshots: Array<{ + id: string; + label: string; + timestamp: number; + reason: string; + cardCount: number; + columnCount: number; + createdBy: string; + boardData: any; + }> = []; + + for (const event of events) { + try { + // Parse tags + const vTag = event.tags.find((t: string[]) => t[0] === 'v'); + const rTag = event.tags.find((t: string[]) => t[0] === 'r'); + const tTag = event.tags.find((t: string[]) => t[0] === 't'); + + const label = vTag ? vTag[1] : 'Unnamed snapshot'; + const reason = rTag ? rTag[1] : 'manual'; + const timestamp = tTag ? parseInt(tTag[1], 10) : (event.created_at || 0); + + // Parse board data from content + let boardData: any = {}; + let cardCount = 0; + let columnCount = 0; + + if (event.content) { + try { + boardData = JSON.parse(event.content); + columnCount = boardData.columns?.length || 0; + cardCount = boardData.columns?.reduce( + (sum: number, col: any) => sum + (col.cards?.length || 0), + 0 + ) || 0; + } catch (parseError) { + console.warn('[NostrIntegration] ⚠️ Failed to parse snapshot content:', parseError); + } + } + + snapshots.push({ + id: event.id || `snapshot-${timestamp}`, + label, + timestamp, + reason, + cardCount, + columnCount, + createdBy: event.pubkey, + boardData, + }); + } catch (eventError) { + console.warn('[NostrIntegration] ⚠️ Failed to process snapshot event:', eventError); + } + } + + // Sort by timestamp (newest first) + snapshots.sort((a, b) => b.timestamp - a.timestamp); + + console.log(`✅ [NostrIntegration] Parsed ${snapshots.length} snapshot(s)`); + + return snapshots; + } + + /** + * 🔖 Fetches a specific snapshot by its label + * + * @param boardId - The board's d-tag ID + * @param boardAuthor - The board owner's pubkey + * @param label - The exact label to search for + * @returns The matching snapshot or null if not found + */ + public async fetchSnapshotByLabel( + boardId: string, + boardAuthor: string, + label: string + ): Promise<{ + id: string; + label: string; + timestamp: number; + boardData: any; + } | null> { + const snapshots = await this.loadSnapshots(boardId, boardAuthor); + return snapshots.find(s => s.label === label) || null; + } + + /** + * 🔍 Fetches a specific snapshot by its event ID + * + * @param snapshotId - The Nostr event ID of the snapshot + * @returns The snapshot data or null if not found + */ + public async fetchSnapshotById( + snapshotId: string + ): Promise<{ + id: string; + label: string; + timestamp: number; + reason: string; + boardData: any; + } | null> { + if (!this.ndk) { + console.error('[NostrIntegration] ❌ NDK not initialized'); + return null; + } + + try { + const event = await this.ndk.fetchEvent({ ids: [snapshotId] }); + + if (!event) { + console.warn(`[NostrIntegration] ⚠️ Snapshot ${snapshotId} not found`); + return null; + } + + const vTag = event.tags.find((t: string[]) => t[0] === 'v'); + const rTag = event.tags.find((t: string[]) => t[0] === 'r'); + const tTag = event.tags.find((t: string[]) => t[0] === 't'); + + let boardData = {}; + if (event.content) { + try { + boardData = JSON.parse(event.content); + } catch { + console.warn('[NostrIntegration] ⚠️ Failed to parse snapshot content'); + } + } + + return { + id: event.id || snapshotId, + label: vTag ? vTag[1] : 'Unnamed', + timestamp: tTag ? parseInt(tTag[1], 10) : (event.created_at || 0), + reason: rTag ? rTag[1] : 'manual', + boardData, + }; + + } catch (error) { + console.error('[NostrIntegration] ❌ Failed to fetch snapshot by ID:', error); + return null; + } + } + /** * Cleanup */ diff --git a/src/lib/stores/kanbanStore.svelte.ts b/src/lib/stores/kanbanStore.svelte.ts index e33cfd37..212893fa 100644 --- a/src/lib/stores/kanbanStore.svelte.ts +++ b/src/lib/stores/kanbanStore.svelte.ts @@ -2483,6 +2483,206 @@ export class BoardStore { this.updateTrigger++; } } + + // ============================================================================ + // BOARD SNAPSHOTS / VERSION HISTORY (Phase 1.5) + // ============================================================================ + + /** + * 🔖 Creates a manual snapshot of the current board state + * + * Publishes a Kind 30303 event to Nostr containing the complete board data. + * Snapshots are non-replaceable, so each snapshot is a permanent record. + * + * @param label - User-provided label/description for this version + * @returns True if snapshot was created successfully + * + * @example + * ```typescript + * await boardStore.createManualSnapshot('Before big refactor'); + * await boardStore.createManualSnapshot('Version 1.0 release'); + * ``` + */ + public async createManualSnapshot(label: string): Promise { + if (!this.nostrIntegration) { + console.error('[BoardStore] ❌ Nostr not initialized - cannot create snapshot'); + return false; + } + + if (!this.board) { + console.error('[BoardStore] ❌ No board loaded - cannot create snapshot'); + return false; + } + + try { + const snapshotId = await this.nostrIntegration.publishSnapshot( + this.board, + label, + 'manual' + ); + + if (snapshotId) { + console.log(`✅ [BoardStore] Snapshot "${label}" created: ${snapshotId}`); + return true; + } else { + console.error('[BoardStore] ❌ Snapshot creation failed - no ID returned'); + return false; + } + } catch (error) { + console.error('[BoardStore] ❌ Failed to create snapshot:', error); + return false; + } + } + + /** + * 🔍 Loads all snapshots for the current board from Nostr + * + * @returns Array of snapshots sorted by timestamp (newest first) + */ + public async loadSnapshots(): Promise> { + if (!this.nostrIntegration) { + console.error('[BoardStore] ❌ Nostr not initialized - cannot load snapshots'); + return []; + } + + if (!this.board) { + console.error('[BoardStore] ❌ No board loaded - cannot load snapshots'); + return []; + } + + const boardAuthor = this.board.author || authStore.getPubkey() || ''; + + if (!boardAuthor) { + console.error('[BoardStore] ❌ No board author - cannot load snapshots'); + return []; + } + + try { + const snapshots = await this.nostrIntegration.loadSnapshots( + this.board.id, + boardAuthor + ); + + console.log(`✅ [BoardStore] Loaded ${snapshots.length} snapshot(s)`); + return snapshots; + } catch (error) { + console.error('[BoardStore] ❌ Failed to load snapshots:', error); + return []; + } + } + + /** + * 🔄 Restores the board to a previous snapshot + * + * This will: + * 1. Create a backup snapshot of current state (before_restore) + * 2. Replace current board data with snapshot data + * 3. Save to localStorage + * 4. Publish updated board to Nostr + * + * @param snapshotId - The event ID of the snapshot to restore + * @returns True if restore was successful + */ + public async rollbackToSnapshot(snapshotId: string): Promise { + if (!this.nostrIntegration) { + console.error('[BoardStore] ❌ Nostr not initialized - cannot rollback'); + return false; + } + + if (!this.board) { + console.error('[BoardStore] ❌ No board loaded - cannot rollback'); + return false; + } + + try { + // 1. Fetch the snapshot + const snapshot = await this.nostrIntegration.fetchSnapshotById(snapshotId); + + if (!snapshot || !snapshot.boardData) { + console.error(`[BoardStore] ❌ Snapshot ${snapshotId} not found or invalid`); + return false; + } + + console.log(`🔄 [BoardStore] Restoring to snapshot "${snapshot.label}"...`); + + // 2. Create backup of current state (before_restore) + await this.nostrIntegration.publishSnapshot( + this.board, + `Backup vor Wiederherstellung: ${snapshot.label}`, + 'before_restore' + ); + console.log(`💾 [BoardStore] Backup snapshot created`); + + // 3. Reconstruct board from snapshot data + const { Board } = await import('../classes/BoardModel.js'); + const restoredBoard = new Board(snapshot.boardData); + + // Preserve the original board ID (don't use snapshot's ID) + const originalId = this.board.id; + restoredBoard.id = originalId; + + // 4. Replace current board + this.board = restoredBoard; + + // 5. Save to localStorage + BoardStorage.saveBoard(this.board); + + // 6. Update UI + this.triggerUpdate({ publish: true }); + + console.log(`✅ [BoardStore] Board restored to snapshot "${snapshot.label}"`); + console.log(` 📊 Cards: ${snapshot.boardData.columns?.reduce((sum: number, col: any) => sum + (col.cards?.length || 0), 0) || 0}`); + console.log(` 📁 Columns: ${snapshot.boardData.columns?.length || 0}`); + + return true; + } catch (error) { + console.error('[BoardStore] ❌ Failed to rollback to snapshot:', error); + return false; + } + } + + /** + * 🔖 Creates an automatic snapshot before a destructive operation + * + * Called automatically before: + * - Import operations (importBoardFromJson) + * - Major board restructuring + * + * @param reason - The reason for the snapshot + * @returns True if snapshot was created successfully + */ + public async createAutoSnapshot(reason: 'before_import' | 'auto_save'): Promise { + if (!this.nostrIntegration || !this.board) { + return false; + } + + const labelMap = { + 'before_import': 'Automatisches Backup vor Import', + 'auto_save': 'Automatisches Backup', + }; + + try { + const snapshotId = await this.nostrIntegration.publishSnapshot( + this.board, + labelMap[reason], + reason + ); + + return !!snapshotId; + } catch (error) { + console.error('[BoardStore] ⚠️ Auto-snapshot failed:', error); + return false; + } + } /** * ⚠️ DEPRECATED & REMOVED: deleteBoardFromNostr() diff --git a/src/lib/stores/syncManager.svelte.ts b/src/lib/stores/syncManager.svelte.ts index 799de803..75ab5fb3 100644 --- a/src/lib/stores/syncManager.svelte.ts +++ b/src/lib/stores/syncManager.svelte.ts @@ -221,8 +221,34 @@ export class SyncManager { if (targetRelays && targetRelays.length > 0) { const plainRelays = Array.isArray(targetRelays) ? [...targetRelays] : targetRelays; console.log(`[SyncManager] Publishing to ${plainRelays.length} target relay(s):`, plainRelays); - const ndkRelays = new Set(plainRelays.map(url => this.ndk.pool.getRelay(url))); - const ndkRelaySet = new NDKRelaySet(ndkRelays, this.ndk); + + // 🔧 FIX: Ensure relays are connected before publishing + const connectedRelays = new Set(); + for (const url of plainRelays) { + try { + // Get relay from pool - it should already be there (added in +layout.svelte) + const relay = this.ndk.pool.getRelay(url); + if (relay) { + // Wait for connection if not connected + if (relay.status !== 1) { // 1 = CONNECTED + console.log(`[SyncManager] Waiting for relay connection: ${url}`); + await relay.connect(); + } + connectedRelays.add(relay); + } else { + console.warn(`[SyncManager] Relay not in pool: ${url} - was it added in +layout.svelte?`); + } + } catch (relayError) { + console.warn(`[SyncManager] Failed to connect relay ${url}:`, relayError); + } + } + + if (connectedRelays.size === 0) { + throw new Error(`No relays connected from: ${plainRelays.join(', ')}`); + } + + console.log(`[SyncManager] ${connectedRelays.size}/${plainRelays.length} relays connected`); + const ndkRelaySet = new NDKRelaySet(connectedRelays, this.ndk); return await event.publish(ndkRelaySet); } console.log('[SyncManager] Publishing to NDK default relays'); diff --git a/src/lib/utils/nostrEvents.ts b/src/lib/utils/nostrEvents.ts index c68f342d..4d44d301 100644 --- a/src/lib/utils/nostrEvents.ts +++ b/src/lib/utils/nostrEvents.ts @@ -27,6 +27,7 @@ import type { BoardProps, CardProps, ColumnProps } from '../classes/BoardModel.j export const EVENT_KINDS = { BOARD: 30301, // Board definition (replaceable) CARD: 30302, // Card definition (replaceable) + SNAPSHOT: 30303, // Board snapshot/version (non-replaceable) - Phase 1.5 COMMENT: 1, // Text note (regular) DELETION: 5, // Event deletion (NIP-09) SOFT_LOCK: 20001, // "Now editing" soft lock (ephemeral) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6c00bbea..d89384aa 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -15,9 +15,14 @@ const { children } = $props(); - // ✅ FIX: Relay-URLs dynamisch aus settingsStore laden statt hardcoded + // ✅ FIX: Relay-URLs dynamisch aus settingsStore laden (public + private für vollständige Konnektivität) + const allRelays = [ + ...settingsStore.settings.relaysPublic, + ...settingsStore.settings.relaysPrivate + ].filter((url, index, arr) => arr.indexOf(url) === index); // Deduplizieren + const ndk = new NDKSvelte({ - explicitRelayUrls: settingsStore.settings.relaysPublic, + explicitRelayUrls: allRelays, enableOutboxModel: false // Deaktiviert Standard-Outbox-Relays }); diff --git a/src/routes/cardsboard/Topbar.svelte b/src/routes/cardsboard/Topbar.svelte index 1e7d47ec..732f76df 100644 --- a/src/routes/cardsboard/Topbar.svelte +++ b/src/routes/cardsboard/Topbar.svelte @@ -27,7 +27,7 @@ import ExportButton from '$lib/components/ExportButton.svelte'; import LiaScriptExportButton from '$lib/components/LiaScriptExportButton.svelte'; import { toast } from 'svelte-sonner'; - import { ShareButton } from '$lib/components/board'; + import { ShareButton, VersionHistory } from '$lib/components/board'; @@ -637,6 +637,9 @@ + + +