From 39a04082fccdd9b123aeccc42ce3c60b33e4faa3 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 1 Nov 2026 20:13:53 +0100 Subject: [PATCH] feat(workspace): bring an extracted note back into the bullet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bullet that is only a link to a note (as left by /extract) gets a "Notiz zurückholen" button. POST /documents/{id}/inline returns the linked note's blocks with fresh ids and trashes the note; the editor puts the title back as text and inserts the blocks as an own edit, so Strg+Z restores the link and the note. Only own notes that no other note links to. Release 0.52.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +- Doku/api/rest.md | 1 + Doku/modules/workspace.md | 1 + README.md | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/features/help/HelpView.tsx | 2 +- .../src/features/workspace/DocumentEditor.tsx | 48 ++++++++- .../src/features/workspace/OutlinerBlock.tsx | 16 +++ .../src/features/workspace/OutlinerTree.tsx | 3 + frontend/src/lib/api.ts | 5 + internal/api/restructure.go | 38 +++++++- internal/api/restructure_test.go | 76 +++++++++++++++ internal/api/server.go | 1 + internal/storage/restructure.go | 97 +++++++++++++++++++ 15 files changed, 293 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b78e1bb..19cfd1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.52.0] - 2026-11-01 + +### Added +- Notizen: Ein ausgelagerter Punkt lässt sich zurückholen. Am Link, der nach `/extract` stehen bleibt, gibt es den Knopf „Notiz zurückholen“: Die Punkte der Notiz kommen wieder als Unterpunkte an diese Stelle, der Link wird wieder zum Text, und die Notiz wandert in den Papierkorb. Strg+Z oder „Rückgängig“ macht das wieder rückgängig. Geht mit eigenen Notizen, die nirgends sonst verlinkt sind. + ## [0.51.0] - 2026-11-01 ### Added @@ -786,7 +791,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...dev +[0.52.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...v0.52.0 [0.51.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...v0.51.0 [0.50.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.2...v0.50.0 [0.49.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.1...v0.49.2 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index a50437c..ecd9709 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -211,6 +211,7 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein | GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) | | POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) | | POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) | +| POST | `/documents/{id}/inline` | `{blockId}`: Gegenstück zu `extract` für einen Punkt, der nur aus `[[Notiz]]` (ggf. mit Hashtags) besteht → `{sourceId, content, blocks}`: Text für den Punkt (Titel plus Hashtags) und die Punkte der Notiz mit neuen IDs (`level` 0 = direkt unter dem Punkt). Die verlinkte Notiz kommt in den Papierkorb; rückgängig mit `POST /documents/{sourceId}/restore`. Die Ursprungsnotiz ändert der Editor selbst. `400` kein Link-Punkt, `404` Notiz nicht gefunden, `403` fremde Notiz, `409` noch aus anderen Notizen verlinkt | | 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 | | 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 | diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index b6a5838..d393057 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -18,6 +18,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (mit Syntax-Highlighting im VS-Code-Stil), `/callout`, `/divider`, `/extract` (Ast als eigene Notiz auslagern), `/image` (Bilder einfügen/hochladen, Aliase `/bild`, `/foto`), `/audio` (Sprachnotiz/Audiodatei, Alias `/sprachnotiz`), `/diktat` (Spracheingabe mit Live-Mitschreiben, Alias `/dictate`) (Liste in `OutlinerBlock.tsx`). Das Menü öffnet sich nur am Textanfang oder nach einem Leerzeichen und schließt mit `Esc` oder `Backspace`. - **Link-Vorschau:** Besteht ein Bulletpoint ausschließlich aus einer einzelnen `https`-Adresse (oder wurde über `/link` in einem leeren Punkt eingefügt), zeigt MindSift darüber eine interaktive Vorschaukarte (`LinkPreviewCard`, serverseitig via `/link-preview`). ✕ blendet die Karte aus und wandelt die Adresse synchronisiert in `[Titel](url)` um. - **Ast auslagern (`/extract`):** Wandelt den aktuellen Punkt samt Unterpunkten in eine eigenständige Notiz im selben Ordner um. Der Server legt die Notiz an und liefert den Link zurück; der Editor ersetzt den Ast lokal über die Sync-Engine als eigene Operation (Strg+Z nimmt die Auslagerung zurück und legt die neue Notiz via `POST /documents/{id}/extract/undo` in den Papierkorb). +- **Notiz zurückholen:** Besteht ein Punkt nur aus einem Link auf eine Notiz (wie nach `/extract`), zeigt seine Knopfleiste „Notiz zurückholen“. `POST /documents/{id}/inline` liefert die Punkte der verlinkten Notiz und legt sie in den Papierkorb; der Editor setzt den Titel (mit den Hashtags des Links) wieder als Text ein und fügt die Punkte als Unterpunkte ein, vor eventuell schon vorhandenen. Strg+Z oder „Rückgängig“ stellt den Link wieder her und holt die Notiz aus dem Papierkorb. Nur mit eigenen Notizen, und nicht, solange andere Notizen sie noch verlinken. - **Notizen zusammenführen (Merge):** Per Drag & Drop einer Notiz auf eine andere in der Seitenleiste oder über das Drei-Punkte-Menü im Notizkopf („Mit anderer Notiz zusammenführen…“). Die eigene Quellnotiz wird als `h2`-Abschnitt angehängt und in den Papierkorb verschoben; der Hinweis bietet vollständiges Rückgängigmachen (`POST /documents/{id}/merge/undo`). - **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`). - **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen. diff --git a/README.md b/README.md index 185894a..06e7182 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.51.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.52.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b404814..71ae6b2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.51.0", + "version": "0.52.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.51.0", + "version": "0.52.0", "license": "AGPL-3.0-only", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 5d01c1b..0b62350 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.51.0", + "version": "0.52.0", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index e4a2fbf..5f95790 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -207,7 +207,7 @@ export const HelpView: React.FC = ({ onBack }) => {
📤 - /extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]]) + /extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]]; der Knopf „Notiz zurückholen“ am Link macht das wieder rückgängig)
🎙️ diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 03d6441..00fc9b4 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -545,7 +545,21 @@ export const DocumentEditor: React.FC = ({ // The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null); + // The last note brought back, so undoing it also takes the note out of the trash again + const lastInlineRef = useRef<{ sourceId: string; blockId: string; content: string } | null>(null); const finishUndoExtract = useCallback(() => { + const inlined = lastInlineRef.current; + if (inlined) { + const block = currentBlocksRef.current.find((b) => b.id === inlined.blockId); + if (!block || block.content !== inlined.content) { + lastInlineRef.current = null; + api + .restoreDocument(inlined.sourceId) + .then(() => api.getDocument(inlined.sourceId)) + .then((restored) => onDocCreated?.(restored)) + .catch((err: any) => showNotice(err?.message || 'Notiz konnte nicht wiederhergestellt werden')); + } + } const info = lastExtractRef.current; if (!info) return; const block = currentBlocksRef.current.find((b) => b.id === info.blockId); @@ -555,7 +569,7 @@ export const DocumentEditor: React.FC = ({ .undoExtract(info.newDocId) .then(() => onDocRemoved?.(info.newDocId)) .catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden')); - }, [onDocRemoved, showNotice]); + }, [onDocCreated, onDocRemoved, showNotice]); useEffect(() => () => { if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); }, []); @@ -614,6 +628,7 @@ export const DocumentEditor: React.FC = ({ while (end < prev.length && (prev[end].level || 0) > level) end++; applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]); } + lastInlineRef.current = null; lastExtractRef.current = { newDocId: created.id, blockId, link }; onDocCreated?.(created); showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [ @@ -627,6 +642,35 @@ export const DocumentEditor: React.FC = ({ [docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks] ); + // The way back: a bullet that only links to a note gets that note's bullets below it again, the note + // goes to the trash. Inserted here as an own edit like extracting, so Strg+Z brings the link back. + const handleInlineBlock = useCallback( + async (blockId: string) => { + const engine = engineRef.current; + if (!engine) return; + try { + await engine.syncNow(); + const res = await api.inlineNote(docId, blockId); + (document.activeElement as HTMLElement | null)?.blur?.(); + const prev = currentBlocksRef.current; + const i = prev.findIndex((b) => b.id === blockId); + if (i >= 0) { + const level = prev[i].level || 0; + const inserted = res.blocks.map((b) => ({ ...b, level: level + 1 + (b.level || 0) })); + // Above any bullets added below the link meanwhile + applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: res.content }, ...inserted, ...prev.slice(i + 1)]); + } + lastExtractRef.current = null; + lastInlineRef.current = { sourceId: res.sourceId, blockId, content: res.content }; + onDocRemoved?.(res.sourceId); + showNotice('Notiz zurückgeholt, sie liegt jetzt im Papierkorb', [{ label: 'Rückgängig', run: () => handleUndo() }]); + } catch (err: any) { + showNotice(err?.message || 'Zurückholen fehlgeschlagen'); + } + }, + [docId, onDocRemoved, showNotice, handleUndo, applyLocalBlocks] + ); + // Keep parent sidebar metadata in sync when blocks/tags change useEffect(() => { if (!docRef.current) return; @@ -1363,6 +1407,7 @@ export const DocumentEditor: React.FC = ({ levelOffset={levelOffset} onZoomBlock={setZoomedBlockId} onExtractBlock={canEdit ? handleExtractBlock : undefined} + onInlineBlock={canEdit ? handleInlineBlock : undefined} onUpdateBlock={handleUpdateBlock} onUpdateBlocks={handleUpdateBlocks} onCreateBlock={handleCreateBlock} @@ -1513,6 +1558,7 @@ export const DocumentEditor: React.FC = ({ levelOffset={0} onZoomBlock={setZoomedBlockId} onExtractBlock={canEdit ? handleExtractBlock : undefined} + onInlineBlock={canEdit ? handleInlineBlock : undefined} onUpdateBlock={handleUpdateBlock} onUpdateBlocks={handleUpdateBlocks} onCreateBlock={handleCreateBlock} diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 6ddc0a6..3208ba2 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -24,6 +24,7 @@ import { Mic, AudioLines, FileOutput, + FileInput, Link2, Type, } from 'lucide-react'; @@ -96,6 +97,7 @@ export interface OutlinerBlockProps { onZoomBlock?: (blockId: string) => void; /** Turns this bullet and its children into a note of its own */ onExtractBlock?: (blockId: string) => void; + onInlineBlock?: (blockId: string) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void; onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void; @@ -151,6 +153,7 @@ export const OutlinerBlock: React.FC = ({ levelOffset = 0, onZoomBlock, onExtractBlock, + onInlineBlock, onUpdateBlock, onCreateBlockBelow, onDeleteBlock, @@ -1724,6 +1727,19 @@ export const OutlinerBlock: React.FC = ({ )} + {/* The way back: a bullet that only links to a note gets that note's bullets again */} + {canEdit && onInlineBlock && isNoteLinkOnly(localContent) && !mediaPicker && ( + + )} + {/* Zoom / Focus button */} {onZoomBlock && (