fix(workspace): extract replaces the branch in the editor, with undo
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m2s
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:
1 parent
02ce554c02
commit
362dd3b4c6
10 files changed
+163
-71
No files matched your search
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user