fix(workspace): extract replaces the branch in the editor, with undo
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m2s

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 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 09:18:25 +01:00
1 parent 02ce554c02
commit 362dd3b4c6
10 files changed
+163 -71

No files matched your search

+3
View File
@@ -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
+2 -1
View File
@@ -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 |
+1 -1
View File
@@ -97,7 +97,7 @@ Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, 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
@@ -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<DocumentEditorProps> = ({
onOpenTask,
onSaveStatusChange,
onRequestMerge,
onDocCreated,
onDocRemoved,
}) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
@@ -381,37 +386,31 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
const [notice, setNotice] = useState<string | null>(null);
const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null);
const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]);
const noticeTimerRef = useRef<number | null>(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<DocumentEditorProps> = ({
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<DocumentEditorProps> = ({
[]
);
// 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<DocumentEditorProps> = ({
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"
>
<span>{notice}</span>
{noticeAction && (
{noticeActions.map((a) => (
<button
key={a.label}
type="button"
onClick={() => {
noticeAction.run();
setNotice(null);
setNoticeAction(null);
setNoticeActions([]);
a.run();
}}
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
>
{noticeAction.label}
{a.label}
</button>
)}
))}
</div>
)}
</div>
@@ -1622,6 +1622,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
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<string, boolean> = {};
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) => {
+8 -3
View File
@@ -709,14 +709,19 @@ export const api = {
return request<TrashedDocument[]>('/api/v1/documents/trash');
},
/** Turns a bullet and its children into a note of its own; the bullet becomes a [[link]]. */
async extractBranch(docId: string, blockId: string, clientId: string): Promise<Document> {
return request<Document>(`/api/v1/documents/${docId}/extract`, {
/** 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<void> {
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<MergeResult> {
return request<MergeResult>(`/api/v1/documents/${targetId}/merge`, {
+17 -3
View File
@@ -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.
+27 -10
View File
@@ -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())
}
+1
View File
@@ -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))
+23 -23
View File
@@ -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 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 {
return err
}
if !doc.IsOwner {
return ErrMergeNotOwner
}
if doc.IsArchived {
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
return s.trashDocument(userID, docID)
}
// copyShares gives the new note the direct and team shares of the source; when someone else owns the