From 362dd3b4c6c7a357b2f427683123a23c7afbb308 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 09:18:25 +0100 Subject: [PATCH] fix(workspace): extract replaces the branch in the editor, with undo The server only creates the new note and returns the link; the editor swaps the branch for it as an ordinary own edit through the sync engine. No more clash with unsent typing (conflict copies), the link shows at once also with polling, and Strg+Z restores the branch. Undoing (toast or Strg+Z) puts the new note into the trash via POST /extract/undo. The sidebar gets the new note right away and opens its folder. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + Doku/api/rest.md | 3 +- Doku/architecture.md | 2 +- .../src/features/workspace/DocumentEditor.tsx | 94 +++++++++++++------ .../src/features/workspace/WorkspaceView.tsx | 17 ++++ frontend/src/lib/api.ts | 11 ++- internal/api/restructure.go | 20 +++- internal/api/restructure_test.go | 37 ++++++-- internal/api/server.go | 1 + internal/storage/restructure.go | 46 ++++----- 10 files changed, 163 insertions(+), 71 deletions(-) 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