feat(workspace): turn a branch into its own note, merge notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s

- 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 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 00:25:46 +01:00
1 parent 4c26eb2960
commit 4d1b37c956
15 files changed
+934 -9

No files matched your search

+2
View File
@@ -7,6 +7,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
### Added ### 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. - 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`. - 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. - 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.
+3
View File
@@ -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` | 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 | | 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` | | 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?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 | | 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) | | 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) |
+4
View File
@@ -95,6 +95,10 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, 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/<id><ext>`) 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/`. Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, 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/<id><ext>`) 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 ## 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:*`, `<title>`, `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. 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:*`, `<title>`, `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.
+7
View File
@@ -160,6 +160,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
'callout', 'callout',
'divider', 'divider',
'bild', 'bild',
'extract',
'auslagern',
'zusammenführen',
'audio', 'audio',
'sprachnotiz', 'sprachnotiz',
'diktat', 'diktat',
@@ -202,6 +205,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<span className="text-accent font-bold">🖼️</span> <span className="text-accent font-bold">🖼️</span>
<span>/image oder /bild (Bild hochladen, fotografieren, einfügen oder per Link; nur im leeren Punkt)</span> <span>/image oder /bild (Bild hochladen, fotografieren, einfügen oder per Link; nur im leeren Punkt)</span>
</div> </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"> <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 className="text-accent font-bold">🎙️</span>
<span>/audio (Sprachnotiz aufnehmen: grüner Knopf startet, roter speichert; oder Audiodatei hochladen)</span> <span>/audio (Sprachnotiz aufnehmen: grüner Knopf startet, roter speichert; oder Audiodatei hochladen)</span>
@@ -17,6 +17,7 @@ import {
Check, Check,
CloudOff, CloudOff,
AlertTriangle, AlertTriangle,
GitMerge,
} from 'lucide-react'; } from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types'; import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
@@ -44,6 +45,8 @@ export interface DocumentEditorProps {
onBackMobile?: () => void; onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void; onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: 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'; import { EmojiPickerModal } from './EmojiPickerModal';
@@ -190,6 +193,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onBackMobile, onBackMobile,
onOpenTask, onOpenTask,
onSaveStatusChange, onSaveStatusChange,
onRequestMerge,
}) => { }) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null); const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || ''); const [title, setTitle] = useState<string>(initialDoc?.title || '');
@@ -375,14 +379,39 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
.catch(() => {}); .catch(() => {});
}, [otherPeople.length]); }, [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 [notice, setNotice] = useState<string | null>(null);
const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null);
const noticeTimerRef = useRef<number | 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); setNotice(message);
setNoticeAction(action || null);
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); 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(() => () => { useEffect(() => () => {
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); 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" /> <Users className="w-3.5 h-3.5" />
</button> </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> </div>
); );
})()} })()}
@@ -969,6 +1008,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
canEdit={canEdit} canEdit={canEdit}
levelOffset={levelOffset} levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId} onZoomBlock={setZoomedBlockId}
onExtractBlock={canEdit ? handleExtractBlock : undefined}
onUpdateBlock={handleUpdateBlock} onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks} onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock} onCreateBlock={handleCreateBlock}
@@ -1056,6 +1096,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
canEdit={canEdit} canEdit={canEdit}
levelOffset={0} levelOffset={0}
onZoomBlock={setZoomedBlockId} onZoomBlock={setZoomedBlockId}
onExtractBlock={canEdit ? handleExtractBlock : undefined}
onUpdateBlock={handleUpdateBlock} onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks} onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock} onCreateBlock={handleCreateBlock}
@@ -1084,7 +1125,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
role="status" 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" 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>
)} )}
</div> </div>
@@ -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>
);
};
@@ -23,6 +23,7 @@ import {
Image as ImageIcon, Image as ImageIcon,
Mic, Mic,
AudioLines, AudioLines,
FileOutput,
} from 'lucide-react'; } from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types'; import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal'; import { LinkModal } from './LinkModal';
@@ -41,7 +42,7 @@ import { api } from '../../lib/api';
import { safeHref } from '../../lib/safeUrl'; import { safeHref } from '../../lib/safeUrl';
export interface SlashCommand { export interface SlashCommand {
type: BlockType | 'link' | 'dictation'; type: BlockType | 'link' | 'dictation' | 'extract';
label: string; label: string;
desc: string; desc: string;
icon: React.ReactNode; 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: '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: '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: '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: '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: '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'] }, { 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; canEdit: boolean;
levelOffset?: number; levelOffset?: number;
onZoomBlock?: (blockId: string) => void; 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; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void; onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void; onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
@@ -123,6 +127,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
canEdit, canEdit,
levelOffset = 0, levelOffset = 0,
onZoomBlock, onZoomBlock,
onExtractBlock,
onUpdateBlock, onUpdateBlock,
onCreateBlockBelow, onCreateBlockBelow,
onDeleteBlock, onDeleteBlock,
@@ -362,11 +367,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
(c) => (c) =>
(!c.media || emptyBullet) && (!c.media || emptyBullet) &&
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) && (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.label.toLowerCase().includes(cmdQuery) ||
c.desc.toLowerCase().includes(cmdQuery) || c.desc.toLowerCase().includes(cmdQuery) ||
(c.aliases || []).some((a) => a.includes(cmdQuery))) (c.aliases || []).some((a) => a.includes(cmdQuery)))
); );
}, [slashSearch, localContent]); }, [slashSearch, localContent, onExtractBlock]);
const applySlashCommand = (cmd: SlashCommand) => { const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null); setSlashSearch(null);
@@ -377,6 +384,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
setShowLinkModal(true); setShowLinkModal(true);
return; return;
} }
if (cmd.type === 'extract') {
setLocalContent(clean);
localContentRef.current = clean;
flushDebounce(clean);
onExtractBlock?.(block.id);
return;
}
if (cmd.media) { if (cmd.media) {
// The typed "/image" must not stay in the synced text; the block type changes only after the upload // The typed "/image" must not stay in the synced text; the block type changes only after the upload
setLocalContent(''); setLocalContent('');
@@ -1272,6 +1286,18 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
)} )}
</div> </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 */} {/* Zoom / Focus button on hover */}
<button <button
type="button" type="button"
@@ -13,6 +13,7 @@ export interface OutlinerTreeProps {
canEdit: boolean; canEdit: boolean;
levelOffset?: number; levelOffset?: number;
onZoomBlock?: (blockId: string) => void; onZoomBlock?: (blockId: string) => void;
onExtractBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onUpdateBlocks?: (updated: DocumentBlock[]) => void; onUpdateBlocks?: (updated: DocumentBlock[]) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void; onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
@@ -37,6 +38,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
canEdit, canEdit,
levelOffset = 0, levelOffset = 0,
onZoomBlock, onZoomBlock,
onExtractBlock,
onUpdateBlock, onUpdateBlock,
onUpdateBlocks, onUpdateBlocks,
onCreateBlock, onCreateBlock,
@@ -783,6 +785,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onRedo={onRedo} onRedo={onRedo}
levelOffset={levelOffset} levelOffset={levelOffset}
onZoomBlock={onZoomBlock} onZoomBlock={onZoomBlock}
onExtractBlock={onExtractBlock}
onUpdateBlock={onUpdateBlock} onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow} onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines} onPasteMultiLines={handlePasteMultiLines}
@@ -27,6 +27,7 @@ import {
import { import {
Document, Document,
DocumentBlock, DocumentBlock,
MergeResult,
DocShare, DocShare,
SharedFolder, SharedFolder,
TagDetails, TagDetails,
@@ -47,6 +48,7 @@ import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext'; import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
import { TrashView } from './TrashView'; import { TrashView } from './TrashView';
import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
import { extractHashtags } from '../../lib/hashtags'; import { extractHashtags } from '../../lib/hashtags';
@@ -87,6 +89,9 @@ interface FolderTreeItemProps {
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void; handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
handleDeleteFolder: (folderPath: string) => void; handleDeleteFolder: (folderPath: string) => void;
onSelectDoc: (id: 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> = ({ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
@@ -106,6 +111,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
handleShareFolderClick, handleShareFolderClick,
handleDeleteFolder, handleDeleteFolder,
onSelectDoc, onSelectDoc,
docDropProps,
mergeHoverId,
}) => { }) => {
const hasContent = node.subfolders.length > 0 || node.docs.length > 0; const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]); const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
@@ -234,6 +241,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
handleShareFolderClick={handleShareFolderClick} handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder} handleDeleteFolder={handleDeleteFolder}
onSelectDoc={onSelectDoc} onSelectDoc={onSelectDoc}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
/> />
))} ))}
@@ -243,10 +252,13 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
key={d.id} key={d.id}
draggable draggable
onDragStart={(e) => handleDocDragStart(e, d.id)} onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => onSelectDoc(d.id)} onClick={() => onSelectDoc(d.id)}
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }} 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 ${ 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' ? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated' : 'border-transparent text-main hover:bg-panel-elevated'
}`} }`}
@@ -378,6 +390,20 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// Drag and Drop state // Drag and Drop state
const [draggingDocId, setDraggingDocId] = useState<string | null>(null); 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 [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null); const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
@@ -674,6 +700,66 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setDraggingDocId(docId); 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) => { const handleFolderDragStart = (e: React.DragEvent, path: string) => {
e.stopPropagation(); e.stopPropagation();
e.dataTransfer.setData('application/mindsift-folder-path', path); e.dataTransfer.setData('application/mindsift-folder-path', path);
@@ -1348,13 +1434,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
key={`pin-${d.id}`} key={`pin-${d.id}`}
draggable draggable
onDragStart={(e) => handleDocDragStart(e, d.id)} onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => { onClick={() => {
setSelectedDocId(d.id); setSelectedDocId(d.id);
setSubView('editor'); setSubView('editor');
setMobileShowDetail(true); 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 ${ 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' ? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated' : 'border-transparent text-main hover:bg-panel-elevated'
}`} }`}
@@ -1398,6 +1487,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setSubView('editor'); setSubView('editor');
setMobileShowDetail(true); setMobileShowDetail(true);
}} }}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
/> />
))} ))}
@@ -1406,13 +1497,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
key={d.id} key={d.id}
draggable draggable
onDragStart={(e) => handleDocDragStart(e, d.id)} onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => { onClick={() => {
setSelectedDocId(d.id); setSelectedDocId(d.id);
setSubView('editor'); setSubView('editor');
setMobileShowDetail(true); 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 ${ 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' ? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated' : 'border-transparent text-main hover:bg-panel-elevated'
}`} }`}
@@ -1527,6 +1621,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onSelectTag={handleSelectTag} onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick} onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock} onOpenDocBlock={handleJumpToDocBlock}
onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })}
onBackMobile={() => setMobileShowDetail(false)} onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => { onSaveStatusChange={(status, err) => {
@@ -2251,6 +2346,38 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</FormSheet> </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) */} {/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
{(shareModalDoc || shareModalFolder) && ( {(shareModalDoc || shareModalFolder) && (
<UnifiedShareModal <UnifiedShareModal
+24
View File
@@ -36,6 +36,7 @@ import {
User, User,
UserInvite, UserInvite,
VoiceAdminStatus, VoiceAdminStatus,
MergeResult,
WSMessage, WSMessage,
} from './types'; } from './types';
import { extractHashtags } from './hashtags'; import { extractHashtags } from './hashtags';
@@ -708,6 +709,29 @@ export const api = {
return request<TrashedDocument[]>('/api/v1/documents/trash'); 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> { async restoreDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}/restore`, { method: 'POST' }); await request(`/api/v1/documents/${id}/restore`, { method: 'POST' });
}, },
+8
View File
@@ -564,3 +564,11 @@ export interface Session {
ip: string; ip: string;
current: boolean; 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;
}
+123
View File
@@ -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})
}
+154
View File
@@ -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())
}
}
+3
View File
@@ -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", s.handleUploadMedia)
r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia)) r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia))
r.Get("/api/v1/media/{id}", s.handleGetMedia) 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", s.notInDemo(s.handleLinkPreview))
r.Get("/api/v1/link-preview/{key}/{kind}", s.handleLinkPreviewPicture) r.Get("/api/v1/link-preview/{key}/{kind}", s.handleLinkPreviewPicture)
r.Get("/api/v1/auth/sessions", s.handleListSessions) r.Get("/api/v1/auth/sessions", s.handleListSessions)
+278
View File
@@ -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
}