diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f9b5a4..fb810dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Fixed +- In eigene Notiz umwandeln: Der Link erscheint sofort im Punkt (auch hinter Proxys ohne Live-Verbindung), ohne Konfliktkopien bei gleichzeitigem Weitertippen; die neue Notiz steht sofort in der Seitenleiste. Strg+Z oder „Rückgängig“ im Hinweis holen den Ast zurück und legen die neue Notiz in den Papierkorb. + ## [0.7.0] - 2026-10-30 ### Added diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 6b5c1ca..678bdb0 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -20,7 +20,8 @@ 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}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt | +| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) | | 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 | diff --git a/Doku/architecture.md b/Doku/architecture.md index 9afd10e..4d11b87 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -97,7 +97,7 @@ Bilder und Audio liegen in `$DATA_DIR/media//.`, beschrieben in ## 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. +`internal/storage/restructure.go`: **Ast auslagern** liest den Ast und legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht). Den Ast in der Ursprungsnotiz ersetzt der Editor selbst durch `[[Titel]]` (Hashtags bleiben am Link) – als eigene Änderung über die Sync-Engine, damit sie nicht mit ungesendeten Eingaben kollidiert und Strg+Z sie zurücknimmt; dabei legt `/extract/undo` die neue Notiz in den Papierkorb. **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 diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index c44327b..e91efec 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -47,6 +47,9 @@ export interface DocumentEditorProps { onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; /** Opens the dialog to merge this note into another one (own notes only) */ onRequestMerge?: (doc: Document) => void; + /** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */ + onDocCreated?: (doc: Document) => void; + onDocRemoved?: (docId: string) => void; } import { EmojiPickerModal } from './EmojiPickerModal'; @@ -194,6 +197,8 @@ export const DocumentEditor: React.FC = ({ onOpenTask, onSaveStatusChange, onRequestMerge, + onDocCreated, + onDocRemoved, }) => { const [doc, setDoc] = useState(initialDoc || null); const [title, setTitle] = useState(initialDoc?.title || ''); @@ -381,37 +386,31 @@ export const DocumentEditor: React.FC = ({ // Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action const [notice, setNotice] = useState(null); - const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null); + const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]); const noticeTimerRef = useRef(null); - const showNotice = useCallback((message: string, action?: { label: string; run: () => void }) => { + const showNotice = useCallback((message: string, actions: { label: string; run: () => void }[] = []) => { setNotice(message); - setNoticeAction(action || null); + setNoticeActions(actions); if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); noticeTimerRef.current = window.setTimeout(() => { setNotice(null); - setNoticeAction(null); - }, action ? 7000 : 3500); + setNoticeActions([]); + }, actions.length ? 8000 : 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] - ); + // 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); + const finishUndoExtract = useCallback(() => { + const info = lastExtractRef.current; + if (!info) return; + const block = currentBlocksRef.current.find((b) => b.id === info.blockId); + if (block && block.content === info.link) return; // the undone step was something else + lastExtractRef.current = null; + api + .undoExtract(info.newDocId) + .then(() => onDocRemoved?.(info.newDocId)) + .catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden')); + }, [onDocRemoved, showNotice]); useEffect(() => () => { if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); }, []); @@ -423,7 +422,9 @@ export const DocumentEditor: React.FC = ({ const handleUndo = useCallback(() => { const res = engineRef.current?.undo(); if (res?.message) showNotice(res.message); - }, [showNotice]); + if (res?.ok) finishUndoExtract(); + }, [showNotice, finishUndoExtract]); + const handleRedo = useCallback(() => { const res = engineRef.current?.redo(); if (res?.message) showNotice(res.message); @@ -449,6 +450,38 @@ export const DocumentEditor: React.FC = ({ [] ); + // A bullet with its children becomes a note of its own. The server only creates the note; the branch + // is replaced here as an ordinary own edit, so it shows at once, syncs like typing and Strg+Z works. + const handleExtractBlock = useCallback( + async (blockId: string) => { + const engine = engineRef.current; + if (!engine) return; + try { + await engine.syncNow(); // the server reads the branch as typed so far + const { document: created, link } = await api.extractBranch(docId, blockId, engine.clientId); + // The focused field keeps its own text while typing; leave it so it shows the link + (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; + let end = i + 1; + while (end < prev.length && (prev[end].level || 0) > level) end++; + applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]); + } + lastExtractRef.current = { newDocId: created.id, blockId, link }; + onDocCreated?.(created); + showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [ + { label: 'Öffnen', run: () => onOpenDocBlock?.(created.id) }, + { label: 'Rückgängig', run: () => handleUndo() }, + ]); + } catch (err: any) { + showNotice(err?.message || 'Umwandeln fehlgeschlagen'); + } + }, + [docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks] + ); + // Keep parent sidebar metadata in sync when blocks/tags change useEffect(() => { if (!docRef.current) return; @@ -1126,19 +1159,20 @@ export const DocumentEditor: React.FC = ({ 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} - {noticeAction && ( + {noticeActions.map((a) => ( - )} + ))} )} diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 0c62ede..b84dac5 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -1622,6 +1622,23 @@ export const WorkspaceView: React.FC = ({ onWikiLinkClick={handleWikiLinkClick} onOpenDocBlock={handleJumpToDocBlock} onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })} + onDocCreated={(created) => { + setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev])); + if (created.folderPath) { + const parts = created.folderPath.split('/'); + const open: Record = {}; + parts.forEach((_, i) => { + open[parts.slice(0, i + 1).join('/')] = true; + }); + setExpandedFolders((prev) => ({ ...prev, ...open })); + } + onStatsChange(); + }} + onDocRemoved={(id) => { + setDocs((prev) => prev.filter((d) => d.id !== id)); + reloadTrash(); + onStatsChange(); + }} onBackMobile={() => setMobileShowDetail(false)} onOpenTask={onOpenTask} onSaveStatusChange={(status, err) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 384db62..d7a37ee 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -709,14 +709,19 @@ export const api = { return request('/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 { - return request(`/api/v1/documents/${docId}/extract`, { + /** Creates a note from a bullet's children; the editor then replaces the branch by `link` itself. */ + async extractBranch(docId: string, blockId: string, clientId: string): Promise<{ document: Document; link: string }> { + return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, { method: 'POST', body: JSON.stringify({ blockId, clientId }), }); }, + /** Puts a note created by extractBranch into the trash again. */ + async undoExtract(newDocId: string): Promise { + await request(`/api/v1/documents/${newDocId}/extract/undo`, { method: 'POST' }); + }, + /** Appends the source note to the target; the source goes to the trash. */ async mergeDocuments(targetId: string, sourceId: string): Promise { return request(`/api/v1/documents/${targetId}/merge`, { diff --git a/internal/api/restructure.go b/internal/api/restructure.go index 9fe857a..06f172f 100644 --- a/internal/api/restructure.go +++ b/internal/api/restructure.go @@ -53,17 +53,31 @@ func (s *Server) handleExtractBranch(w http.ResponseWriter, r *http.Request) { 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)) + created, link, 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) + writeJSON(w, http.StatusCreated, map[string]interface{}{"document": created, "link": link}) +} + +// handleUndoExtract: POST /documents/{id}/extract/undo puts the extracted note {id} into the trash. +func (s *Server) handleUndoExtract(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + docID := chi.URLParam(r, "id") + users, _ := s.store.GetAuthorizedDocUsers(docID) + if err := s.store.UndoExtract(userID, docID); err != nil { + writeError(w, restructureStatus(err), err.Error()) + return + } + for _, uid := range users { + s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID}) + } + writeJSON(w, http.StatusOK, map[string]bool{"undone": true}) } // handleMergeDocuments: POST /documents/{id}/merge {sourceId, clientId} appends the source to this note. diff --git a/internal/api/restructure_test.go b/internal/api/restructure_test.go index b83a375..883000c 100644 --- a/internal/api/restructure_test.go +++ b/internal/api/restructure_test.go @@ -41,16 +41,21 @@ func TestExtractBranchIntoOwnNote(t *testing.T) { 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" { + var out struct { + Document domain.Document + Link string + } + _ = json.Unmarshal(rec.Body.Bytes(), &out) + created := out.Document + if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" || out.Link != "[[Konzept: Garten]] #draußen" { 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) + // The server leaves the source alone; the editor replaces the branch itself + if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "- Konzept: Garten #draußen|>Beete|>>Erde kaufen|>Zaun|Danach" { + t.Fatalf("source must stay unchanged on the server: %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) @@ -61,10 +66,21 @@ func TestExtractBranchIntoOwnNote(t *testing.T) { p2 = d.ID } } - var second domain.Document + var second struct{ Document 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) + if second.Document.Title != "Konzept: Garten (2)" { + t.Fatalf("duplicate title: %q", second.Document.Title) + } + // Undo puts the new note into the trash; only its creator may + _, strangerToken := newTestUser(t, store, "fremd2@example.com") + if rec := sessionCall(router, "POST", "/api/v1/documents/"+created.ID+"/extract/undo", strangerToken, "", ""); rec.Code == http.StatusOK { + t.Fatal("stranger must not undo") + } + if rec := sessionCall(router, "POST", "/api/v1/documents/"+created.ID+"/extract/undo", token, "", ""); rec.Code != http.StatusOK { + t.Fatalf("undo: %d %s", rec.Code, rec.Body.String()) + } + if d, _ := store.GetDocumentByID(uid, created.ID); d == nil || !d.IsArchived { + t.Fatal("extracted note must be in the trash after undo") } } @@ -87,8 +103,9 @@ func TestExtractInSharedNoteKeepsAccess(t *testing.T) { 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) + var out struct{ Document domain.Document } + _ = json.Unmarshal(rec.Body.Bytes(), &out) + created := out.Document if rec.Code != http.StatusCreated { t.Fatalf("editor extract: %d %s", rec.Code, rec.Body.String()) } diff --git a/internal/api/server.go b/internal/api/server.go index f9fb765..75e46a0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -136,6 +136,7 @@ func (s *Server) Router() http.Handler { 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}/extract/undo", s.handleUndoExtract) 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)) diff --git a/internal/storage/restructure.go b/internal/storage/restructure.go index deb74a7..ba52a96 100644 --- a/internal/storage/restructure.go +++ b/internal/storage/restructure.go @@ -92,16 +92,17 @@ func foreignLeaseIn(ids []string, a collab.Actor, leases collab.LeaseView) bool 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) { +// ExtractBranch creates a new note from a bullet's children, named after the bullet, in the same folder +// and shared like the original. The original note is not changed here: the editor replaces the branch +// by the returned link itself, as an ordinary own edit (no clash with unsent typing, undo works). +func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, leases collab.LeaseView) (*domain.Document, string, error) { source, err := s.GetDocumentByID(userID, docID) if err != nil { - return nil, err + return nil, "", err } state, err := s.readCollabDoc(docID) if err != nil { - return nil, err + return nil, "", err } flat := state.Flatten() start := -1 @@ -112,7 +113,7 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea } } if start < 0 { - return nil, ErrBlockNotFound + return nil, "", ErrBlockNotFound } root := flat[start] var children []domain.DocumentBlock @@ -122,37 +123,36 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea ids = append(ids, flat[i].ID) } if foreignLeaseIn(ids, a, leases) { - return nil, ErrBranchLocked + 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 + 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 - }) + return created, link, nil +} + +// UndoExtract puts a note created by ExtractBranch into the trash (only its creator may). +func (s *Store) UndoExtract(userID, docID string) error { + doc, err := s.GetDocumentByID(userID, docID) if err != nil { - _ = s.hardDeleteDocument(created.ID) - return nil, err + return err } - return created, nil + if !doc.IsOwner { + return ErrMergeNotOwner + } + if doc.IsArchived { + return nil + } + return s.trashDocument(userID, docID) } // copyShares gives the new note the direct and team shares of the source; when someone else owns the