From 4d1b37c95612c9d7f3163c9a1933d4a060e3d784 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 00:25:46 +0100 Subject: [PATCH] feat(workspace): turn a branch into its own note, merge notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract: button on the bullet (desktop) or /extract (/notiz). The bullet becomes the title (hashtags stay at the link), its children the content of a new note in the same folder with the source's shares (a foreign source owner gets write access); the branch becomes [[title]] through the operation log, so open editors follow. - Merge: drop a note onto another in the sidebar or use "Mit anderer Notiz zusammenführen…" in the note header. The own source is appended as an h2 section with its bullets below and goes to the trash; the toast offers a complete undo. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + Doku/api/rest.md | 3 + Doku/architecture.md | 4 + frontend/src/features/help/HelpView.tsx | 7 + .../src/features/workspace/DocumentEditor.tsx | 62 +++- .../src/features/workspace/MergeDialog.tsx | 109 +++++++ .../src/features/workspace/OutlinerBlock.tsx | 30 +- .../src/features/workspace/OutlinerTree.tsx | 3 + .../src/features/workspace/WorkspaceView.tsx | 133 ++++++++- frontend/src/lib/api.ts | 24 ++ frontend/src/lib/types.ts | 8 + internal/api/restructure.go | 123 ++++++++ internal/api/restructure_test.go | 154 ++++++++++ internal/api/server.go | 3 + internal/storage/restructure.go | 278 ++++++++++++++++++ 15 files changed, 934 insertions(+), 9 deletions(-) create mode 100644 frontend/src/features/workspace/MergeDialog.tsx create mode 100644 internal/api/restructure.go create mode 100644 internal/api/restructure_test.go create mode 100644 internal/storage/restructure.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 91bf7d0..40ba21b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] ### Added +- Punkt in eigene Notiz umwandeln: Knopf am Punkt (Desktop) oder `/extract` bzw. `/notiz`. Der Punkt wird Titel, seine Unterpunkte der Inhalt der neuen Notiz im selben Ordner; an seiner Stelle bleibt ein `[[Link]]` (Hashtags bleiben dort). In geteilten Notizen übernimmt die neue Notiz die Freigaben. +- Notizen zusammenführen: in der Seitenleiste eine Notiz auf eine andere ziehen oder im Kopf der Notiz „Mit anderer Notiz zusammenführen…“. Die Quelle wird als Abschnitt (Überschrift mit Unterpunkten) angehängt und wandert in den Papierkorb; „Rückgängig“ im Hinweis macht beides rückgängig. - Eigene Spracherkennung (optional): Mit `STT_URL` schickt MindSift Aufnahmen an einen selbst betriebenen Whisper-Server (OpenAI-kompatibel, z. B. speaches) statt an Google oder Microsoft. Einstellung nur per Umgebungsvariablen (`STT_*`) inklusive Grenzwerten; `docker-compose.yml` enthält den Dienst `whisper` als auskommentierten Block zum Einschalten, das Modell lädt MindSift beim ersten Start selbst. Administration → Server & Isolation zeigt den aktiven Modus und bietet eine Testaufnahme. In der Demo bleibt es bei der Browser-Erkennung. - API: `POST /voice/transcribe`, `GET /admin/voice`; `/auth/status` meldet `voice.mode`. - Bilder in Notizen: `/image` (auch `/bild`) in einem leeren Punkt öffnet direkt im Punkt eine Auswahl – Datei, Kamera (Handy), Hineinziehen, Einfügen mit Strg+V oder Bild-Link (der Server übernimmt eine Kopie). Fotos werden vor dem Hochladen auf höchstens 2048 px verkleinert und als WebP gespeichert, Standortdaten entfallen dabei. Klick vergrößert, darunter eine optionale Beschriftung. Esc oder ✕ macht den Punkt wieder zum leeren Punkt. diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 1785ff9..6b5c1ca 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -20,6 +20,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll | | POST | `/documents/{id}/media/fetch` | Externes Bild kopieren: `{url}` (nur https, keine internen Adressen; nicht in der Demo) → wie oben | | GET | `/media/{id}` | Datei abrufen (Leserecht auf eine Notiz, die sie zeigt), mit Range-Anfragen; sonst `404` | +| POST | `/documents/{id}/extract` | `{blockId, clientId}`: Punkt samt Unterpunkten wird eigene Notiz (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht); der Punkt wird zu `[[Titel]]`. Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt | +| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` | +| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen | | GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe | | GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau | | POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) | diff --git a/Doku/architecture.md b/Doku/architecture.md index 4cc550e..9afd10e 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -95,6 +95,10 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in Bilder und Audio liegen in `$DATA_DIR/media//.`, beschrieben in der Tabelle `media` (Migration 5, `internal/storage/media.go`). Ein Block verweist über seinen Inhalt darauf, z. B. Typ `image` mit `![Beschriftung](media:med_…)`; damit laufen Verweise ohne Änderung am Sync-Modell durch Synchronisation, Rückgängig und Konflikte. Hochladen (`POST /documents/{id}/media`) braucht Schreibrecht auf die Notiz, Lesen (`GET /media/{id}`) Leserecht auf die Notiz der Datei oder auf eine Notiz, deren Blöcke sie zeigen (kopierte Blöcke). Das Format wird am Inhalt erkannt (JPEG, PNG, GIF, WebP; WebM, Ogg, MP3, WAV, M4A), SVG und alles andere wird abgelehnt; Antworten tragen eine eigene CSP mit `sandbox`. Externe Bilder kopiert der Server einmal (`POST /documents/{id}/media/fetch`, nur https und öffentliche Adressen wie bei Webhooks, nicht in der Demo). Grenzen: `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB`, `MEDIA_QUOTA_MB`, in der Demo `MEDIA_DEMO_MAX_MB`. Backup-ZIP (`media/`) und Markdown-Archiv (`attachments/`, Einbettung `![Beschriftung](../attachments/…)` relativ zur Notiz) enthalten die Dateien der eigenen Notizen; beim Import bekommen sie neue IDs. Der Obsidian-Import übernimmt Bilder und Audio, die eine Notiz allein in einem Punkt einbettet (`![[datei.png]]` oder `![](pfad/datei.png)`); andere Dateien erscheinen in der Vorschau als übersprungen. Die Verdichtung (alle 6 Stunden) löscht Dateien, die kein Block mehr erwähnt (auch keine gelöschten im Papierkorb oder Protokoll), frühestens einen Tag nach dem Hochladen. Der Demo-Reset leert `media/`. +## Notizen umbauen + +`internal/storage/restructure.go`: **Ast auslagern** liest den Ast, legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht) und ersetzt den Ast dann über das Operationsprotokoll durch `[[Titel]]` (Hashtags bleiben am Link). **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen. + ## Link-Vorschau Punkte mit einer reinen https-Adresse zeigen darüber eine Karte (`LinkPreviewCard`, `lib/linkPreview.ts`). `GET /link-preview?url=` holt die Seite serverseitig mit demselben Schutz wie Webhooks (nur https, keine internen Adressen, Weiterleitungen geprüft), liest `og:*`/`twitter:*`, ``, `description` und das Icon aus dem `<head>` (höchstens 1 MB HTML, Bild 2 MB, Icon 100 KB, kein SVG) und speichert alles in `link_previews` (Migration 6): Erfolg 7 Tage, nichts Brauchbares 1 Tag, nach 30 Tagen gelöscht. Bilder kommen über `GET /link-preview/{key}/image|icon` mit Login. Pro Adresse höchstens 300 Abrufe in 15 Minuten (Antworten aus dem Cache zählen nicht). Ausblenden einer Karte macht aus der Adresse `[Titel](url)` (ein benannter Link hat keine Karte), damit synchronisiert die Wahl mit der Notiz; „Link-Vorschauen anzeigen“ unter Darstellung gilt pro Gerät. Im Demo-Modus aus. diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 2f7b43e..d10c821 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -160,6 +160,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => { 'callout', 'divider', 'bild', + 'extract', + 'auslagern', + 'zusammenführen', 'audio', 'sprachnotiz', 'diktat', @@ -202,6 +205,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => { <span className="text-accent font-bold">🖼️</span> <span>/image oder /bild (Bild hochladen, fotografieren, einfügen oder per Link; nur im leeren Punkt)</span> </div> + <div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2"> + <span className="text-accent font-bold">📤</span> + <span>/extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]])</span> + </div> <div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2"> <span className="text-accent font-bold">🎙️</span> <span>/audio (Sprachnotiz aufnehmen: grüner Knopf startet, roter speichert; oder Audiodatei hochladen)</span> diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 9eac611..c44327b 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -17,6 +17,7 @@ import { Check, CloudOff, AlertTriangle, + GitMerge, } from 'lucide-react'; import { Document, DocumentBlock, User } from '../../lib/types'; import { api } from '../../lib/api'; @@ -44,6 +45,8 @@ export interface DocumentEditorProps { onBackMobile?: () => void; onOpenTask?: (taskId: string) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; + /** Opens the dialog to merge this note into another one (own notes only) */ + onRequestMerge?: (doc: Document) => void; } import { EmojiPickerModal } from './EmojiPickerModal'; @@ -190,6 +193,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({ onBackMobile, onOpenTask, onSaveStatusChange, + onRequestMerge, }) => { const [doc, setDoc] = useState<Document | null>(initialDoc || null); const [title, setTitle] = useState<string>(initialDoc?.title || ''); @@ -375,14 +379,39 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({ .catch(() => {}); }, [otherPeople.length]); - // Short hint at the bottom of the editor (blocked actions, skipped undo) + // Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action const [notice, setNotice] = useState<string | null>(null); + const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null); const noticeTimerRef = useRef<number | null>(null); - const showNotice = useCallback((message: string) => { + const showNotice = useCallback((message: string, action?: { label: string; run: () => void }) => { setNotice(message); + setNoticeAction(action || null); if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); - noticeTimerRef.current = window.setTimeout(() => setNotice(null), 3500); + noticeTimerRef.current = window.setTimeout(() => { + setNotice(null); + setNoticeAction(null); + }, action ? 7000 : 3500); }, []); + + // A bullet with its children becomes a note of its own; the server swaps the branch for a [[link]] + // and this editor receives that change through the normal sync + const handleExtractBlock = useCallback( + async (blockId: string) => { + const engine = engineRef.current; + try { + await engine?.syncNow(); // the branch as typed so far + const created = await api.extractBranch(docId, blockId, engine?.clientId || ''); + await engine?.syncNow(); + showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, { + label: 'Öffnen', + run: () => onOpenDocBlock?.(created.id), + }); + } catch (err: any) { + showNotice(err?.message || 'Umwandeln fehlgeschlagen'); + } + }, + [docId, onOpenDocBlock, showNotice] + ); useEffect(() => () => { if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); }, []); @@ -817,6 +846,16 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({ > <Users className="w-3.5 h-3.5" /> </button> + {onRequestMerge && doc.isOwner && !inTrash && ( + <button + type="button" + onClick={() => onRequestMerge(doc)} + className="btn-icon" + title="Mit anderer Notiz zusammenführen…" + > + <GitMerge className="w-3.5 h-3.5" /> + </button> + )} </div> ); })()} @@ -969,6 +1008,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({ canEdit={canEdit} levelOffset={levelOffset} onZoomBlock={setZoomedBlockId} + onExtractBlock={canEdit ? handleExtractBlock : undefined} onUpdateBlock={handleUpdateBlock} onUpdateBlocks={handleUpdateBlocks} onCreateBlock={handleCreateBlock} @@ -1056,6 +1096,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({ canEdit={canEdit} levelOffset={0} onZoomBlock={setZoomedBlockId} + onExtractBlock={canEdit ? handleExtractBlock : undefined} onUpdateBlock={handleUpdateBlock} onUpdateBlocks={handleUpdateBlocks} onCreateBlock={handleCreateBlock} @@ -1084,7 +1125,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({ role="status" className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel" > - {notice} + <span>{notice}</span> + {noticeAction && ( + <button + type="button" + onClick={() => { + noticeAction.run(); + setNotice(null); + setNoticeAction(null); + }} + className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0" + > + {noticeAction.label} + </button> + )} </div> )} </div> diff --git a/frontend/src/features/workspace/MergeDialog.tsx b/frontend/src/features/workspace/MergeDialog.tsx new file mode 100644 index 0000000..0ab9765 --- /dev/null +++ b/frontend/src/features/workspace/MergeDialog.tsx @@ -0,0 +1,109 @@ +import React, { useMemo, useState } from 'react'; +import { GitMerge, Search } from 'lucide-react'; +import { FormSheet } from '../../components/FormSheet'; +import { api } from '../../lib/api'; +import { Document, MergeResult } from '../../lib/types'; + +interface MergeDialogProps { + docs: Document[]; + sourceId: string; + /** Known after a drop in the sidebar; otherwise the user picks it here */ + targetId?: string | null; + onClose: () => void; + onMerged: (result: MergeResult) => void; +} + +const canWrite = (d: Document) => d.isOwner || d.permission === 'owner' || d.permission === 'write'; + +// Merging a note into another: pick the target (when not dropped onto one), confirm, merge. The source +// is appended to the target under a heading with its title and goes to the trash. +export const MergeDialog: React.FC<MergeDialogProps> = ({ docs, sourceId, targetId, onClose, onMerged }) => { + const [target, setTarget] = useState<string | null>(targetId || null); + const [query, setQuery] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + const source = docs.find((d) => d.id === sourceId); + const targetDoc = docs.find((d) => d.id === target); + + const candidates = useMemo(() => { + const q = query.trim().toLowerCase(); + return docs + .filter((d) => d.id !== sourceId && !d.isArchived && canWrite(d)) + .filter((d) => !q || (d.title || '').toLowerCase().includes(q)) + .slice(0, 50); + }, [docs, sourceId, query]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!target) return; + setBusy(true); + setError(null); + try { + onMerged(await api.mergeDocuments(target, sourceId)); + } catch (err: any) { + setError(err?.message || 'Zusammenführen fehlgeschlagen'); + setBusy(false); + } + }; + + const title = (d?: Document) => `„${d?.title || 'Unbenannte Notiz'}“`; + + return ( + <FormSheet + title="Notizen zusammenführen" + icon={<GitMerge className="w-4 h-4" />} + onClose={onClose} + onSubmit={submit} + submitLabel={target ? 'Zusammenführen' : 'Ziel wählen'} + busy={busy || !target} + > + {targetDoc ? ( + <div className="space-y-2 text-xs"> + <p className="text-main"> + Notiz {title(source)} in {title(targetDoc)} zusammenführen? + </p> + <p className="text-muted"> + Die Punkte von {title(source)} werden als eigener Abschnitt an {title(targetDoc)} angehängt.{' '} + {title(source)} wandert danach in den Papierkorb; „Rückgängig“ macht beides wieder rückgängig. + </p> + {!targetId && ( + <button type="button" onClick={() => setTarget(null)} className="text-accent hover:underline min-h-[44px] sm:min-h-0"> + Anderes Ziel wählen + </button> + )} + </div> + ) : ( + <div className="space-y-2"> + <p className="text-xs text-muted">In welche Notiz soll {title(source)} eingefügt werden?</p> + <div className="relative"> + <Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" /> + <input + autoFocus + value={query} + onChange={(e) => setQuery(e.target.value)} + placeholder="Notiz suchen…" + className="w-full h-11 sm:h-9 pl-8 pr-2 input-field text-sm" + /> + </div> + <ul className="max-h-64 overflow-y-auto divide-y divide-border rounded-card border border-border"> + {candidates.map((d) => ( + <li key={d.id}> + <button + type="button" + onClick={() => setTarget(d.id)} + className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated" + > + <span className="shrink-0">{d.icon || '📝'}</span> + <span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span> + {d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>} + </button> + </li> + ))} + {candidates.length === 0 && <li className="px-3 py-3 text-xs text-faint">Keine passende Notiz</li>} + </ul> + </div> + )} + {error && <p className="text-xs text-status-err">{error}</p>} + </FormSheet> + ); +}; diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 2b2ad88..08f29ef 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -23,6 +23,7 @@ import { Image as ImageIcon, Mic, AudioLines, + FileOutput, } from 'lucide-react'; import { DocumentBlock, BlockType } from '../../lib/types'; import { LinkModal } from './LinkModal'; @@ -41,7 +42,7 @@ import { api } from '../../lib/api'; import { safeHref } from '../../lib/safeUrl'; export interface SlashCommand { - type: BlockType | 'link' | 'dictation'; + type: BlockType | 'link' | 'dictation' | 'extract'; label: string; desc: string; icon: React.ReactNode; @@ -60,6 +61,7 @@ const SLASH_COMMANDS: SlashCommand[] = [ { type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: <Code className="w-3.5 h-3.5" /> }, { type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: <MessageSquare className="w-3.5 h-3.5" /> }, { type: 'divider', label: '/divider', desc: 'Trennlinie', icon: <Minus className="w-3.5 h-3.5" /> }, + { type: 'extract', label: '/extract', desc: 'Ast als eigene Notiz', icon: <FileOutput className="w-3.5 h-3.5" />, aliases: ['notiz', 'auslagern'] }, { type: 'image', label: '/image', desc: 'Bilder hochladen oder verlinken', icon: <ImageIcon className="w-3.5 h-3.5" />, media: true, aliases: ['bild', 'foto'] }, { type: 'audio', label: '/audio', desc: 'Sprachnotiz oder Audiodatei', icon: <Mic className="w-3.5 h-3.5" />, media: true, aliases: ['sprachnotiz'] }, { type: 'dictation', label: '/diktat', desc: 'Sprechen statt tippen', icon: <AudioLines className="w-3.5 h-3.5" />, media: true, aliases: ['dictate'] }, @@ -76,6 +78,8 @@ export interface OutlinerBlockProps { canEdit: boolean; levelOffset?: number; onZoomBlock?: (blockId: string) => void; + /** Turns this bullet and its children into a note of its own */ + onExtractBlock?: (blockId: string) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void; onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void; @@ -123,6 +127,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({ canEdit, levelOffset = 0, onZoomBlock, + onExtractBlock, onUpdateBlock, onCreateBlockBelow, onDeleteBlock, @@ -362,11 +367,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({ (c) => (!c.media || emptyBullet) && (c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) && + // The bullet's text becomes the title of the new note + (c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) && (c.label.toLowerCase().includes(cmdQuery) || c.desc.toLowerCase().includes(cmdQuery) || (c.aliases || []).some((a) => a.includes(cmdQuery))) ); - }, [slashSearch, localContent]); + }, [slashSearch, localContent, onExtractBlock]); const applySlashCommand = (cmd: SlashCommand) => { setSlashSearch(null); @@ -377,6 +384,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({ setShowLinkModal(true); return; } + if (cmd.type === 'extract') { + setLocalContent(clean); + localContentRef.current = clean; + flushDebounce(clean); + onExtractBlock?.(block.id); + return; + } if (cmd.media) { // The typed "/image" must not stay in the synced text; the block type changes only after the upload setLocalContent(''); @@ -1272,6 +1286,18 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({ )} </div> + {/* Branch into its own note (desktop; phones use /extract) */} + {canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && ( + <button + type="button" + onClick={() => onExtractBlock(block.id)} + className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0" + title="In eigene Notiz umwandeln (mit allen Unterpunkten)" + > + <FileOutput className="w-3.5 h-3.5" /> + </button> + )} + {/* Zoom / Focus button on hover */} <button type="button" diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 37f6680..9547957 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -13,6 +13,7 @@ export interface OutlinerTreeProps { canEdit: boolean; levelOffset?: number; onZoomBlock?: (blockId: string) => void; + onExtractBlock?: (blockId: string) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onUpdateBlocks?: (updated: DocumentBlock[]) => void; onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void; @@ -37,6 +38,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({ canEdit, levelOffset = 0, onZoomBlock, + onExtractBlock, onUpdateBlock, onUpdateBlocks, onCreateBlock, @@ -783,6 +785,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({ onRedo={onRedo} levelOffset={levelOffset} onZoomBlock={onZoomBlock} + onExtractBlock={onExtractBlock} onUpdateBlock={onUpdateBlock} onCreateBlockBelow={handleCreateBlockBelow} onPasteMultiLines={handlePasteMultiLines} diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 7df2e50..0c62ede 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -27,6 +27,7 @@ import { import { Document, DocumentBlock, + MergeResult, DocShare, SharedFolder, TagDetails, @@ -47,6 +48,7 @@ import { sendDocOps } from '../../lib/docSync/oneoff'; import { useMobileActions } from '../../context/MobileActionContext'; import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; import { TrashView } from './TrashView'; +import { MergeDialog } from './MergeDialog'; import { CountBadge } from '../../components/CountBadge'; import { extractHashtags } from '../../lib/hashtags'; @@ -87,6 +89,9 @@ interface FolderTreeItemProps { handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void; handleDeleteFolder: (folderPath: string) => void; onSelectDoc: (id: string) => void; + // Dropping a note onto another one offers to merge them + docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>; + mergeHoverId: string | null; } const FolderTreeItem: React.FC<FolderTreeItemProps> = ({ @@ -106,6 +111,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({ handleShareFolderClick, handleDeleteFolder, onSelectDoc, + docDropProps, + mergeHoverId, }) => { const hasContent = node.subfolders.length > 0 || node.docs.length > 0; const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]); @@ -234,6 +241,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({ handleShareFolderClick={handleShareFolderClick} handleDeleteFolder={handleDeleteFolder} onSelectDoc={onSelectDoc} + docDropProps={docDropProps} + mergeHoverId={mergeHoverId} /> ))} @@ -243,10 +252,13 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({ key={d.id} draggable onDragStart={(e) => handleDocDragStart(e, d.id)} +{...docDropProps(d.id)} onClick={() => onSelectDoc(d.id)} style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }} className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${ - selectedDocId === d.id && subView === 'editor' + mergeHoverId === d.id + ? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main' + : selectedDocId === d.id && subView === 'editor' ? 'bg-panel-elevated text-main font-semibold border-border/80' : 'border-transparent text-main hover:bg-panel-elevated' }`} @@ -378,6 +390,20 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({ // Drag and Drop state const [draggingDocId, setDraggingDocId] = useState<string | null>(null); + + // Merging notes: dialog (picker/confirmation) and a toast with undo afterwards + const [mergeDialog, setMergeDialog] = useState<{ sourceId: string; targetId?: string } | null>(null); + const [mergeHoverId, setMergeHoverId] = useState<string | null>(null); + const [toast, setToast] = useState<{ text: string; actions: { label: string; run: () => void }[] } | null>(null); + const toastTimerRef = useRef<number | null>(null); + const showToast = useCallback((text: string, actions: { label: string; run: () => void }[] = []) => { + setToast({ text, actions }); + if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current); + toastTimerRef.current = window.setTimeout(() => setToast(null), actions.length ? 10000 : 4000); + }, []); + useEffect(() => () => { + if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current); + }, []); const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null); const [dragOverFolder, setDragOverFolder] = useState<string | null>(null); @@ -674,6 +700,66 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({ setDraggingDocId(docId); }; + const requestMerge = (sourceId: string, targetId?: string) => { + const source = docs.find((d) => d.id === sourceId); + if (!source?.isOwner) { + showToast('Nur eigene Notizen lassen sich in eine andere einfügen'); + return; + } + setMergeDialog({ sourceId, targetId }); + }; + + // Dropping a dragged note onto another note (instead of a folder) offers to merge them + const docDropProps = (targetId: string): React.HTMLAttributes<HTMLDivElement> => ({ + onDragOver: (e) => { + if (!draggingDocId || draggingDocId === targetId) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + if (mergeHoverId !== targetId) setMergeHoverId(targetId); + }, + onDragLeave: () => setMergeHoverId((cur) => (cur === targetId ? null : cur)), + onDrop: (e) => { + const sourceId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId; + if (!sourceId || sourceId === targetId) return; + e.preventDefault(); + e.stopPropagation(); + setDraggingDocId(null); + setMergeHoverId(null); + setDragOverFolder(null); + requestMerge(sourceId, targetId); + }, + }); + + const handleMerged = async (result: MergeResult) => { + setMergeDialog(null); + triggerHaptic(20); + setDocs((prev) => prev.filter((d) => d.id !== result.sourceId)); + setSelectedDocId(result.targetId); + setTargetBlockId(null); + setSubView('editor'); + setMobileShowDetail(true); + setEditorNonce((n) => n + 1); + reloadTrash(); + showToast(`„${result.sourceTitle || 'Notiz'}“ wurde angehängt`, [ + { + label: 'Rückgängig', + run: async () => { + try { + await api.undoMerge(result); + await loadWorkspaceData(); + setSelectedDocId(result.sourceId); + setEditorNonce((n) => n + 1); + reloadTrash(); + showToast('Zusammenführen rückgängig gemacht'); + } catch (err: any) { + showToast(err?.message || 'Rückgängig fehlgeschlagen'); + } + }, + }, + ]); + }; + const handleFolderDragStart = (e: React.DragEvent, path: string) => { e.stopPropagation(); e.dataTransfer.setData('application/mindsift-folder-path', path); @@ -1348,13 +1434,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({ key={`pin-${d.id}`} draggable onDragStart={(e) => handleDocDragStart(e, d.id)} +{...docDropProps(d.id)} onClick={() => { setSelectedDocId(d.id); setSubView('editor'); setMobileShowDetail(true); }} className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${ - selectedDocId === d.id && subView === 'editor' + mergeHoverId === d.id + ? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main' + : selectedDocId === d.id && subView === 'editor' ? 'bg-panel-elevated text-main font-semibold border-border/80' : 'border-transparent text-main hover:bg-panel-elevated' }`} @@ -1398,6 +1487,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({ setSubView('editor'); setMobileShowDetail(true); }} + docDropProps={docDropProps} + mergeHoverId={mergeHoverId} /> ))} @@ -1406,13 +1497,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({ key={d.id} draggable onDragStart={(e) => handleDocDragStart(e, d.id)} +{...docDropProps(d.id)} onClick={() => { setSelectedDocId(d.id); setSubView('editor'); setMobileShowDetail(true); }} className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${ - selectedDocId === d.id && subView === 'editor' + mergeHoverId === d.id + ? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main' + : selectedDocId === d.id && subView === 'editor' ? 'bg-panel-elevated text-main font-semibold border-border/80' : 'border-transparent text-main hover:bg-panel-elevated' }`} @@ -1527,6 +1621,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({ onSelectTag={handleSelectTag} onWikiLinkClick={handleWikiLinkClick} onOpenDocBlock={handleJumpToDocBlock} + onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })} onBackMobile={() => setMobileShowDetail(false)} onOpenTask={onOpenTask} onSaveStatusChange={(status, err) => { @@ -2251,6 +2346,38 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({ </FormSheet> )} + {mergeDialog && ( + <MergeDialog + docs={docs} + sourceId={mergeDialog.sourceId} + targetId={mergeDialog.targetId} + onClose={() => setMergeDialog(null)} + onMerged={(r) => void handleMerged(r)} + /> + )} + + {toast && ( + <div + role="status" + className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 z-50 max-w-[calc(100vw-1.5rem)] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel flex items-center gap-3" + > + <span>{toast.text}</span> + {toast.actions.map((a) => ( + <button + key={a.label} + type="button" + onClick={() => { + setToast(null); + a.run(); + }} + className="font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0 shrink-0" + > + {a.label} + </button> + ))} + </div> + )} + {/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */} {(shareModalDoc || shareModalFolder) && ( <UnifiedShareModal diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index deb7dbe..384db62 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -36,6 +36,7 @@ import { User, UserInvite, VoiceAdminStatus, + MergeResult, WSMessage, } from './types'; import { extractHashtags } from './hashtags'; @@ -708,6 +709,29 @@ export const api = { return request<TrashedDocument[]>('/api/v1/documents/trash'); }, + /** Turns a bullet and its children into a note of its own; the bullet becomes a [[link]]. */ + async extractBranch(docId: string, blockId: string, clientId: string): Promise<Document> { + return request<Document>(`/api/v1/documents/${docId}/extract`, { + method: 'POST', + body: JSON.stringify({ blockId, clientId }), + }); + }, + + /** Appends the source note to the target; the source goes to the trash. */ + async mergeDocuments(targetId: string, sourceId: string): Promise<MergeResult> { + return request<MergeResult>(`/api/v1/documents/${targetId}/merge`, { + method: 'POST', + body: JSON.stringify({ sourceId }), + }); + }, + + async undoMerge(m: MergeResult): Promise<void> { + await request(`/api/v1/documents/${m.targetId}/merge/undo`, { + method: 'POST', + body: JSON.stringify({ sourceId: m.sourceId, headingId: m.headingId }), + }); + }, + async restoreDocument(id: string): Promise<void> { await request(`/api/v1/documents/${id}/restore`, { method: 'POST' }); }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 22fab91..f67cbc4 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -564,3 +564,11 @@ export interface Session { ip: string; current: boolean; } + +// Answer of merging two notes; kept by the client to undo it +export interface MergeResult { + targetId: string; + sourceId: string; + sourceTitle: string; + headingId: string; +} diff --git a/internal/api/restructure.go b/internal/api/restructure.go new file mode 100644 index 0000000..9fe857a --- /dev/null +++ b/internal/api/restructure.go @@ -0,0 +1,123 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/collab" + "github.com/sandrodach/mindsift/internal/storage" +) + +// Turning a branch into its own note and merging notes (storage/restructure.go). The editor sends its +// client ID, so its own block leases do not block the change. + +func (s *Server) restructureActor(r *http.Request, clientID string) collab.Actor { + userID := auth.GetUserID(r.Context()) + a := collab.Actor{UserID: userID, ClientID: clientID} + if u, err := s.store.GetUserByID(userID); err == nil { + a.Name = u.DisplayName + } + return a +} + +func restructureStatus(err error) int { + switch { + case errors.Is(err, storage.ErrBranchLocked): + return http.StatusConflict + case errors.Is(err, storage.ErrBlockNotFound), errors.Is(err, storage.ErrDocNotFound): + return http.StatusNotFound + case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash): + return http.StatusBadRequest + case errors.Is(err, storage.ErrMergeNotOwner): + return http.StatusForbidden + } + return http.StatusInternalServerError +} + +// handleExtractBranch: POST /documents/{id}/extract {blockId, clientId} +func (s *Server) handleExtractBranch(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + docID := chi.URLParam(r, "id") + if !storage.CanEditDoc(s.store.DocRole(userID, docID)) { + writeError(w, http.StatusForbidden, "Keine Schreibrechte für diese Notiz") + return + } + var req struct { + BlockID string `json:"blockId"` + ClientID string `json:"clientId"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.BlockID == "" { + writeError(w, http.StatusBadRequest, "blockId fehlt") + return + } + created, err := s.store.ExtractBranch(userID, docID, req.BlockID, s.restructureActor(r, req.ClientID), s.leases.View(docID)) + if err != nil { + writeError(w, restructureStatus(err), err.Error()) + return + } + s.notifyDocChanged(docID, s.store.DocHead(docID)) + if full, err := s.store.GetDocumentByID(userID, created.ID); err == nil { + created = full + } + s.broadcastDocToAuthorizedUsers(created.ID, "DOC_CREATED") + writeJSON(w, http.StatusCreated, created) +} + +// handleMergeDocuments: POST /documents/{id}/merge {sourceId, clientId} appends the source to this note. +func (s *Server) handleMergeDocuments(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + targetID := chi.URLParam(r, "id") + if !storage.CanEditDoc(s.store.DocRole(userID, targetID)) { + writeError(w, http.StatusForbidden, "Keine Schreibrechte für die Zielnotiz") + return + } + var req struct { + SourceID string `json:"sourceId"` + ClientID string `json:"clientId"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.SourceID == "" { + writeError(w, http.StatusBadRequest, "sourceId fehlt") + return + } + sourceUsers, _ := s.store.GetAuthorizedDocUsers(req.SourceID) + res, err := s.store.MergeDocuments(userID, req.SourceID, targetID, s.restructureActor(r, req.ClientID), s.leases.View(targetID)) + if err != nil { + writeError(w, restructureStatus(err), err.Error()) + return + } + s.notifyDocChanged(targetID, s.store.DocHead(targetID)) + for _, uid := range sourceUsers { + s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": req.SourceID}) + } + writeJSON(w, http.StatusOK, res) +} + +// handleUndoMerge: POST /documents/{id}/merge/undo {sourceId, headingId, clientId} +func (s *Server) handleUndoMerge(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + targetID := chi.URLParam(r, "id") + if !storage.CanEditDoc(s.store.DocRole(userID, targetID)) { + writeError(w, http.StatusForbidden, "Keine Schreibrechte für die Zielnotiz") + return + } + var req struct { + SourceID string `json:"sourceId"` + HeadingID string `json:"headingId"` + ClientID string `json:"clientId"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.SourceID == "" || req.HeadingID == "" { + writeError(w, http.StatusBadRequest, "sourceId und headingId fehlen") + return + } + m := storage.MergeResult{TargetID: targetID, SourceID: req.SourceID, HeadingID: req.HeadingID} + if err := s.store.UndoMerge(userID, m, s.restructureActor(r, req.ClientID), s.leases.View(targetID)); err != nil { + writeError(w, restructureStatus(err), err.Error()) + return + } + s.notifyDocChanged(targetID, s.store.DocHead(targetID)) + s.broadcastDocToAuthorizedUsers(req.SourceID, "DOC_CREATED") + writeJSON(w, http.StatusOK, map[string]bool{"undone": true}) +} diff --git a/internal/api/restructure_test.go b/internal/api/restructure_test.go new file mode 100644 index 0000000..b83a375 --- /dev/null +++ b/internal/api/restructure_test.go @@ -0,0 +1,154 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/domain" + "github.com/sandrodach/mindsift/internal/storage" +) + +func flatOf(t *testing.T, store *storage.Store, userID, docID string) []string { + t.Helper() + d, err := store.GetDocumentByID(userID, docID) + if err != nil { + t.Fatal(err) + } + out := make([]string, 0, len(d.Blocks)) + for _, b := range d.Blocks { + out = append(out, strings.Repeat(">", b.Level)+b.Content) + } + return out +} + +func TestExtractBranchIntoOwnNote(t *testing.T) { + srv, store, token := setupTestServer(t) + router := srv.Router() + claims, _ := auth.ValidateToken(testJWTSecret, token) + uid := claims.UserID + src, _ := store.CreateDocument(uid, "", "Projekt", "", "Arbeit", []domain.DocumentBlock{ + {ID: "blk_a", Type: "bullet", Content: "- Konzept: Garten #draußen", Level: 0, Position: 1}, + {ID: "blk_b", Type: "bullet", Content: "Beete", Level: 1, Position: 2}, + {ID: "blk_c", Type: "todo", Content: "Erde kaufen", Level: 2, Position: 3, Checked: true}, + {ID: "blk_d", Type: "bullet", Content: "Zaun", Level: 1, Position: 4}, + {ID: "blk_e", Type: "bullet", Content: "Danach", Level: 0, Position: 5}, + }, nil, false) + + rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", token, `{"blockId":"blk_a"}`, "") + if rec.Code != http.StatusCreated { + t.Fatalf("extract: %d %s", rec.Code, rec.Body.String()) + } + var created domain.Document + _ = json.Unmarshal(rec.Body.Bytes(), &created) + if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" { + t.Fatalf("new note: %q in %q", created.Title, created.FolderPath) + } + if got := strings.Join(flatOf(t, store, uid, created.ID), "|"); got != "Beete|>Erde kaufen|Zaun" { + t.Fatalf("new note blocks: %s", got) + } + if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "[[Konzept: Garten]] #draußen|Danach" { + t.Fatalf("source after extract: %s", got) + } + // Same title again: the link must stay unique + _, _ = store.CreateDocument(uid, "", "Projekt 2", "", "", []domain.DocumentBlock{{ID: "blk_x", Type: "bullet", Content: "Konzept: Garten", Level: 0}}, nil, false) + docs, _ := store.GetDocuments(uid) + var p2 string + for _, d := range docs { + if d.Title == "Projekt 2" { + p2 = d.ID + } + } + var second domain.Document + _ = json.Unmarshal(sessionCall(router, "POST", "/api/v1/documents/"+p2+"/extract", token, `{"blockId":"blk_x"}`, "").Body.Bytes(), &second) + if second.Title != "Konzept: Garten (2)" { + t.Fatalf("duplicate title: %q", second.Title) + } +} + +func TestExtractInSharedNoteKeepsAccess(t *testing.T) { + srv, store, _ := setupTestServer(t) + router := srv.Router() + owner, _ := newTestUser(t, store, "owner@example.com") + editor, editorToken := newTestUser(t, store, "editor@example.com") + reader, readerToken := newTestUser(t, store, "reader@example.com") + src, _ := store.CreateDocument(owner.ID, "", "Gemeinsam", "", "", []domain.DocumentBlock{ + {ID: "blk_s1", Type: "bullet", Content: "Idee", Level: 0, Position: 1}, + {ID: "blk_s2", Type: "bullet", Content: "Detail", Level: 1, Position: 2}, + }, nil, false) + if _, err := store.CreateDocShare(owner.ID, src.ID, editor.ID, "write"); err != nil { + t.Fatal(err) + } + _, _ = store.CreateDocShare(owner.ID, src.ID, reader.ID, "read") + + if rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", readerToken, `{"blockId":"blk_s1"}`, ""); rec.Code != http.StatusForbidden { + t.Fatalf("reader must not extract: %d", rec.Code) + } + rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", editorToken, `{"blockId":"blk_s1"}`, "") + var created domain.Document + _ = json.Unmarshal(rec.Body.Bytes(), &created) + if rec.Code != http.StatusCreated { + t.Fatalf("editor extract: %d %s", rec.Code, rec.Body.String()) + } + if r := store.DocRole(editor.ID, created.ID); r != storage.DocRoleOwner { + t.Fatalf("editor should own the new note, got %q", r) + } + if r := store.DocRole(owner.ID, created.ID); !storage.CanEditDoc(r) { + t.Fatalf("source owner must be able to edit the new note, got %q", r) + } + if r := store.DocRole(reader.ID, created.ID); r == "" || storage.CanEditDoc(r) { + t.Fatalf("reader must read (not edit) the new note, got %q", r) + } +} + +func TestMergeDocumentsAndUndo(t *testing.T) { + srv, store, token := setupTestServer(t) + router := srv.Router() + claims, _ := auth.ValidateToken(testJWTSecret, token) + uid := claims.UserID + source, _ := store.CreateDocument(uid, "", "Einkauf", "🛒", "", []domain.DocumentBlock{ + {ID: "blk_m1", Type: "bullet", Content: "Milch", Level: 0, Position: 1}, + {ID: "blk_m2", Type: "bullet", Content: "laktosefrei", Level: 1, Position: 2}, + }, nil, false) + target, _ := store.CreateDocument(uid, "", "Haushalt", "", "", []domain.DocumentBlock{{ID: "blk_t1", Type: "bullet", Content: "Putzen", Level: 0}}, nil, false) + + if rec := sessionCall(router, "POST", "/api/v1/documents/"+target.ID+"/merge", token, `{"sourceId":"`+target.ID+`"}`, ""); rec.Code != http.StatusBadRequest { + t.Fatalf("merge into itself: want 400, got %d", rec.Code) + } + rec := sessionCall(router, "POST", "/api/v1/documents/"+target.ID+"/merge", token, `{"sourceId":"`+source.ID+`"}`, "") + if rec.Code != http.StatusOK { + t.Fatalf("merge: %d %s", rec.Code, rec.Body.String()) + } + var res storage.MergeResult + _ = json.Unmarshal(rec.Body.Bytes(), &res) + got := flatOf(t, store, uid, target.ID) + if len(got) != 4 || got[0] != "Putzen" || !strings.HasPrefix(got[1], "🛒 Einkauf (") || got[2] != ">Milch" || got[3] != ">>laktosefrei" { + t.Fatalf("target after merge: %v", got) + } + if d, _ := store.GetDocumentByID(uid, source.ID); d == nil || !d.IsArchived { + t.Fatal("source must be in the trash") + } + + undo := `{"sourceId":"` + source.ID + `","headingId":"` + res.HeadingID + `"}` + if rec := sessionCall(router, "POST", "/api/v1/documents/"+target.ID+"/merge/undo", token, undo, ""); rec.Code != http.StatusOK { + t.Fatalf("undo: %d %s", rec.Code, rec.Body.String()) + } + if got := strings.Join(flatOf(t, store, uid, target.ID), "|"); got != "Putzen" { + t.Fatalf("target after undo: %s", got) + } + if d, _ := store.GetDocumentByID(uid, source.ID); d == nil || d.IsArchived { + t.Fatal("source must be back") + } + + // Someone else's note cannot be the source + other, otherToken := newTestUser(t, store, "andere@example.com") + own, _ := store.CreateDocument(other.ID, "", "Eigen", "", "", nil, nil, false) + if _, err := store.CreateDocShare(uid, target.ID, other.ID, "write"); err != nil { + t.Fatal(err) + } + if rec := sessionCall(router, "POST", "/api/v1/documents/"+own.ID+"/merge", otherToken, `{"sourceId":"`+target.ID+`"}`, ""); rec.Code != http.StatusForbidden { + t.Fatalf("foreign source: want 403, got %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 4bd1d36..f9fb765 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -135,6 +135,9 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/documents/{id}/media", s.handleUploadMedia) r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia)) r.Get("/api/v1/media/{id}", s.handleGetMedia) + r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch) + r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments) + r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge) r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview)) r.Get("/api/v1/link-preview/{key}/{kind}", s.handleLinkPreviewPicture) r.Get("/api/v1/auth/sessions", s.handleListSessions) diff --git a/internal/storage/restructure.go b/internal/storage/restructure.go new file mode 100644 index 0000000..deb74a7 --- /dev/null +++ b/internal/storage/restructure.go @@ -0,0 +1,278 @@ +package storage + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" + + "github.com/sandrodach/mindsift/internal/collab" + "github.com/sandrodach/mindsift/internal/domain" +) + +// Restructuring notes: turn a bullet with its children into a note of its own, and merge one note into +// another. Both change the affected notes through the normal operation log, so open editors follow +// live, and nothing is lost: an extracted branch lives on in the new note, a merged note goes to the +// trash and the merge can be undone. + +var ( + ErrBlockNotFound = errors.New("Punkt nicht gefunden") + ErrBranchLocked = errors.New("Jemand anderes bearbeitet gerade einen Punkt in diesem Ast. Bitte kurz warten.") + ErrMergeSelf = errors.New("Eine Notiz kann nicht mit sich selbst zusammengeführt werden") + ErrMergeNotOwner = errors.New("Zusammenführen geht nur mit eigenen Notizen als Quelle") +) + +// readCollabDoc loads the current state of a note outside of a change. +func (s *Store) readCollabDoc(docID string) (*collab.Doc, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + doc, _, err := loadCollabDoc(tx, docID) + return doc, err +} + +var ( + titlePrefixRe = regexp.MustCompile(`^(\s*(-|\*|#{1,6}|\[[ xX]?\])\s+)+`) + titleTagRe = regexp.MustCompile(`(^|\s)#[\p{L}0-9_-]+`) +) + +// branchTitle makes a note title from a bullet's text (without hashtags) and returns the hashtags, +// which stay next to the link in the original bullet. +func branchTitle(content string) (string, string) { + first := titlePrefixRe.ReplaceAllString(strings.SplitN(content, "\n", 2)[0], "") + var tags []string + for _, m := range titleTagRe.FindAllString(first, -1) { + tags = append(tags, strings.TrimSpace(m)) + } + t := strings.TrimSpace(titleTagRe.ReplaceAllString(first, "")) + t = strings.NewReplacer("[[", "", "]]", "").Replace(t) + if r := []rune(t); len(r) > 120 { + t = strings.TrimSpace(string(r[:119])) + "…" + } + if t == "" { + t = "Unbenannte Notiz" + } + return t, strings.Join(tags, " ") +} + +// uniqueTitle appends " (2)", " (3)" … when the user already has a note of that name, so the wiki link +// points to exactly one note. +func (s *Store) uniqueTitle(userID, title string) string { + docs, _ := s.GetDocuments(userID) + taken := map[string]bool{} + for _, d := range docs { + taken[strings.ToLower(d.Title)] = true + } + candidate := title + for n := 2; taken[strings.ToLower(candidate)]; n++ { + candidate = fmt.Sprintf("%s (%d)", title, n) + } + return candidate +} + +func toDocumentBlock(b collab.FlatBlock, level int, pos int) domain.DocumentBlock { + return domain.DocumentBlock{ + ID: domain.NewID("blk"), Type: b.Type, Content: b.Content, Level: level, Position: float64(pos + 1), + Checked: b.Props.Checked, Collapsed: b.Props.Collapsed, Lang: b.Props.Lang, + } +} + +func foreignLeaseIn(ids []string, a collab.Actor, leases collab.LeaseView) bool { + if leases == nil { + return false + } + for _, id := range ids { + if holder, ok := leases.HolderOf(id); ok && holder != a.ClientID { + return true + } + } + return false +} + +// ExtractBranch moves a bullet's children into a new note named after the bullet, in the same folder, +// shared like the original; the bullet itself becomes a [[link]] to it. Returns the new note. +func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, leases collab.LeaseView) (*domain.Document, error) { + source, err := s.GetDocumentByID(userID, docID) + if err != nil { + return nil, err + } + state, err := s.readCollabDoc(docID) + if err != nil { + return nil, err + } + flat := state.Flatten() + start := -1 + for i, b := range flat { + if b.ID == blockID { + start = i + break + } + } + if start < 0 { + return nil, ErrBlockNotFound + } + root := flat[start] + var children []domain.DocumentBlock + ids := []string{root.ID} + for i := start + 1; i < len(flat) && flat[i].Level > root.Level; i++ { + children = append(children, toDocumentBlock(flat[i], flat[i].Level-root.Level-1, len(children))) + ids = append(ids, flat[i].ID) + } + if foreignLeaseIn(ids, a, leases) { + return nil, ErrBranchLocked + } + + base, tags := branchTitle(root.Content) + title := s.uniqueTitle(userID, base) + created, err := s.CreateDocument(userID, "", title, "📄", source.FolderPath, children, nil, false) + if err != nil { + return nil, err + } + s.copyShares(userID, source, created.ID) + + link := "[[" + title + "]]" + if tags != "" { + link += " " + tags + } + _, _, err = s.applyDocChange(docID, a, leases, func(d *collab.Doc) []collab.Op { + b, ok := d.Blocks[blockID] + if !ok || b.Deleted { + return nil + } + ops := []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: blockID, BaseVersion: b.Version, Content: &link}} + for _, c := range d.Children(blockID) { + ops = append(ops, collab.Op{OpID: domain.NewID("op"), Kind: collab.OpDelete, BlockID: c.ID}) + } + return ops + }) + if err != nil { + _ = s.hardDeleteDocument(created.ID) + return nil, err + } + return created, nil +} + +// copyShares gives the new note the direct and team shares of the source; when someone else owns the +// source, that owner may edit the new note as well (the branch was theirs to read before). +func (s *Store) copyShares(userID string, source *domain.Document, newDocID string) { + shares, _ := s.GetDocShares(source.ID) + for _, sh := range shares { + switch { + case sh.TargetType == "team": + _, _ = s.CreateDocTeamShare(userID, newDocID, sh.TargetID, sh.Permission) + case sh.UserID != "" && sh.UserID != userID: + _, _ = s.CreateDocShare(userID, newDocID, sh.UserID, sh.Permission) + } + } + if source.UserID != "" && source.UserID != userID { + _, _ = s.CreateDocShare(userID, newDocID, source.UserID, "write") + } +} + +// MergeResult tells the client what to undo. +type MergeResult struct { + TargetID string `json:"targetId"` + SourceID string `json:"sourceId"` + SourceTitle string `json:"sourceTitle"` + HeadingID string `json:"headingId"` +} + +// MergeDocuments appends the source note to the end of the target: a heading with the source's title, +// its blocks below it. The source goes to the trash (it must belong to the user; the caller checks +// write access to the target). +func (s *Store) MergeDocuments(userID, sourceID, targetID string, a collab.Actor, leases collab.LeaseView) (*MergeResult, error) { + if sourceID == targetID { + return nil, ErrMergeSelf + } + source, err := s.GetDocumentByID(userID, sourceID) + if err != nil { + return nil, err + } + if !source.IsOwner { + return nil, ErrMergeNotOwner + } + if source.IsArchived { + return nil, ErrDocInTrash + } + state, err := s.readCollabDoc(sourceID) + if err != nil { + return nil, err + } + flat := state.Flatten() + + headingID := domain.NewID("blk") + heading := strings.TrimSpace(source.Icon + " " + source.Title) + if heading == "" { + heading = "Zusammengeführte Notiz" + } + heading += " (" + time.Now().Format("02.01.2006") + ")" + + _, _, err = s.applyDocChange(targetID, a, leases, func(d *collab.Doc) []collab.Op { + // After the last top-level block, deleted ones included, so keys never collide + last := "" + for _, b := range d.Blocks { + if b.ParentID == "" && b.OrderKey > last { + last = b.OrderKey + } + } + key, err := collab.KeyBetween(last, "") + if err != nil { + return nil + } + root := "" + ops := []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: headingID, ParentID: &root, OrderKey: key, Type: "h2", Content: &heading}} + // Parents by level: the heading holds the source's top level + parents := []string{headingID} + lastKey := map[string]string{} + for _, b := range flat { + if b.Level+1 > len(parents) { + continue // cannot happen with a flattened tree; guards against gaps + } + parents = parents[:b.Level+1] + parent := parents[b.Level] + k, err := collab.KeyBetween(lastKey[parent], "") + if err != nil { + return nil + } + lastKey[parent] = k + id := domain.NewID("blk") + content := b.Content + props := collab.Props{Checked: b.Props.Checked, Collapsed: b.Props.Collapsed, Lang: b.Props.Lang} + p := parent + ops = append(ops, collab.Op{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: id, ParentID: &p, OrderKey: k, Type: b.Type, Content: &content, Props: &props}) + parents = append(parents, id) + } + return ops + }) + if err != nil { + return nil, err + } + if err := s.trashDocument(userID, sourceID); err != nil { + return nil, err + } + return &MergeResult{TargetID: targetID, SourceID: sourceID, SourceTitle: source.Title, HeadingID: headingID}, nil +} + +// UndoMerge removes the appended section from the target and brings the source back from the trash. +func (s *Store) UndoMerge(userID string, m MergeResult, a collab.Actor, leases collab.LeaseView) error { + if err := s.RestoreDocument(userID, m.SourceID); err != nil { + return err + } + _, _, err := s.applyDocChange(m.TargetID, a, leases, func(d *collab.Doc) []collab.Op { + if b, ok := d.Blocks[m.HeadingID]; !ok || b.Deleted { + return nil + } + return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpDelete, BlockID: m.HeadingID}} + }) + return err +} + +// DocHead is the newest log position of a note (to tell open editors to sync). +func (s *Store) DocHead(docID string) int64 { + var head int64 + _ = s.db.QueryRow(`SELECT head_seq FROM documents WHERE id = ?`, docID).Scan(&head) + return head +}