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]
|
## [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
|
## [0.7.0] - 2026-10-30
|
||||||
|
|
||||||
### Added
|
### 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` | 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 |
|
| 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` |
|
| 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` | `{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}/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 |
|
| 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
|
## 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
|
## Link-Vorschau
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export interface DocumentEditorProps {
|
|||||||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||||
/** Opens the dialog to merge this note into another one (own notes only) */
|
/** Opens the dialog to merge this note into another one (own notes only) */
|
||||||
onRequestMerge?: (doc: Document) => void;
|
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';
|
import { EmojiPickerModal } from './EmojiPickerModal';
|
||||||
@@ -194,6 +197,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onOpenTask,
|
onOpenTask,
|
||||||
onSaveStatusChange,
|
onSaveStatusChange,
|
||||||
onRequestMerge,
|
onRequestMerge,
|
||||||
|
onDocCreated,
|
||||||
|
onDocRemoved,
|
||||||
}) => {
|
}) => {
|
||||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
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
|
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
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 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);
|
setNotice(message);
|
||||||
setNoticeAction(action || null);
|
setNoticeActions(actions);
|
||||||
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
|
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
|
||||||
noticeTimerRef.current = window.setTimeout(() => {
|
noticeTimerRef.current = window.setTimeout(() => {
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
setNoticeAction(null);
|
setNoticeActions([]);
|
||||||
}, action ? 7000 : 3500);
|
}, actions.length ? 8000 : 3500);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// A bullet with its children becomes a note of its own; the server swaps the branch for a [[link]]
|
// The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash
|
||||||
// and this editor receives that change through the normal sync
|
const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null);
|
||||||
const handleExtractBlock = useCallback(
|
const finishUndoExtract = useCallback(() => {
|
||||||
async (blockId: string) => {
|
const info = lastExtractRef.current;
|
||||||
const engine = engineRef.current;
|
if (!info) return;
|
||||||
try {
|
const block = currentBlocksRef.current.find((b) => b.id === info.blockId);
|
||||||
await engine?.syncNow(); // the branch as typed so far
|
if (block && block.content === info.link) return; // the undone step was something else
|
||||||
const created = await api.extractBranch(docId, blockId, engine?.clientId || '');
|
lastExtractRef.current = null;
|
||||||
await engine?.syncNow();
|
api
|
||||||
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, {
|
.undoExtract(info.newDocId)
|
||||||
label: 'Öffnen',
|
.then(() => onDocRemoved?.(info.newDocId))
|
||||||
run: () => onOpenDocBlock?.(created.id),
|
.catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden'));
|
||||||
});
|
}, [onDocRemoved, showNotice]);
|
||||||
} catch (err: any) {
|
|
||||||
showNotice(err?.message || 'Umwandeln fehlgeschlagen');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[docId, onOpenDocBlock, showNotice]
|
|
||||||
);
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
|
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -423,7 +422,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const handleUndo = useCallback(() => {
|
const handleUndo = useCallback(() => {
|
||||||
const res = engineRef.current?.undo();
|
const res = engineRef.current?.undo();
|
||||||
if (res?.message) showNotice(res.message);
|
if (res?.message) showNotice(res.message);
|
||||||
}, [showNotice]);
|
if (res?.ok) finishUndoExtract();
|
||||||
|
}, [showNotice, finishUndoExtract]);
|
||||||
|
|
||||||
const handleRedo = useCallback(() => {
|
const handleRedo = useCallback(() => {
|
||||||
const res = engineRef.current?.redo();
|
const res = engineRef.current?.redo();
|
||||||
if (res?.message) showNotice(res.message);
|
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
|
// Keep parent sidebar metadata in sync when blocks/tags change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!docRef.current) return;
|
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"
|
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>
|
<span>{notice}</span>
|
||||||
{noticeAction && (
|
{noticeActions.map((a) => (
|
||||||
<button
|
<button
|
||||||
|
key={a.label}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
noticeAction.run();
|
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
setNoticeAction(null);
|
setNoticeActions([]);
|
||||||
|
a.run();
|
||||||
}}
|
}}
|
||||||
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
|
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
|
||||||
>
|
>
|
||||||
{noticeAction.label}
|
{a.label}
|
||||||
</button>
|
</button>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1622,6 +1622,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
onWikiLinkClick={handleWikiLinkClick}
|
onWikiLinkClick={handleWikiLinkClick}
|
||||||
onOpenDocBlock={handleJumpToDocBlock}
|
onOpenDocBlock={handleJumpToDocBlock}
|
||||||
onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })}
|
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)}
|
onBackMobile={() => setMobileShowDetail(false)}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onSaveStatusChange={(status, err) => {
|
onSaveStatusChange={(status, err) => {
|
||||||
|
|||||||
@@ -709,14 +709,19 @@ export const api = {
|
|||||||
return request<TrashedDocument[]>('/api/v1/documents/trash');
|
return request<TrashedDocument[]>('/api/v1/documents/trash');
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Turns a bullet and its children into a note of its own; the bullet becomes a [[link]]. */
|
/** 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> {
|
async extractBranch(docId: string, blockId: string, clientId: string): Promise<{ document: Document; link: string }> {
|
||||||
return request<Document>(`/api/v1/documents/${docId}/extract`, {
|
return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ blockId, clientId }),
|
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. */
|
/** Appends the source note to the target; the source goes to the trash. */
|
||||||
async mergeDocuments(targetId: string, sourceId: string): Promise<MergeResult> {
|
async mergeDocuments(targetId: string, sourceId: string): Promise<MergeResult> {
|
||||||
return request<MergeResult>(`/api/v1/documents/${targetId}/merge`, {
|
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")
|
writeError(w, http.StatusBadRequest, "blockId fehlt")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, restructureStatus(err), err.Error())
|
writeError(w, restructureStatus(err), err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.notifyDocChanged(docID, s.store.DocHead(docID))
|
|
||||||
if full, err := s.store.GetDocumentByID(userID, created.ID); err == nil {
|
if full, err := s.store.GetDocumentByID(userID, created.ID); err == nil {
|
||||||
created = full
|
created = full
|
||||||
}
|
}
|
||||||
s.broadcastDocToAuthorizedUsers(created.ID, "DOC_CREATED")
|
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.
|
// 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 {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("extract: %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("extract: %d %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
var created domain.Document
|
var out struct {
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &created)
|
Document domain.Document
|
||||||
if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" {
|
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)
|
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" {
|
if got := strings.Join(flatOf(t, store, uid, created.ID), "|"); got != "Beete|>Erde kaufen|Zaun" {
|
||||||
t.Fatalf("new note blocks: %s", got)
|
t.Fatalf("new note blocks: %s", got)
|
||||||
}
|
}
|
||||||
if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "[[Konzept: Garten]] #draußen|Danach" {
|
// The server leaves the source alone; the editor replaces the branch itself
|
||||||
t.Fatalf("source after extract: %s", got)
|
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
|
// 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)
|
_, _ = 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
|
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)
|
_ = json.Unmarshal(sessionCall(router, "POST", "/api/v1/documents/"+p2+"/extract", token, `{"blockId":"blk_x"}`, "").Body.Bytes(), &second)
|
||||||
if second.Title != "Konzept: Garten (2)" {
|
if second.Document.Title != "Konzept: Garten (2)" {
|
||||||
t.Fatalf("duplicate title: %q", second.Title)
|
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)
|
t.Fatalf("reader must not extract: %d", rec.Code)
|
||||||
}
|
}
|
||||||
rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", editorToken, `{"blockId":"blk_s1"}`, "")
|
rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", editorToken, `{"blockId":"blk_s1"}`, "")
|
||||||
var created domain.Document
|
var out struct{ Document domain.Document }
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &created)
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||||
|
created := out.Document
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("editor extract: %d %s", rec.Code, rec.Body.String())
|
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.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia))
|
||||||
r.Get("/api/v1/media/{id}", s.handleGetMedia)
|
r.Get("/api/v1/media/{id}", s.handleGetMedia)
|
||||||
r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch)
|
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", s.handleMergeDocuments)
|
||||||
r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge)
|
r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge)
|
||||||
r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview))
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractBranch moves a bullet's children into a new note named after the bullet, in the same folder,
|
// ExtractBranch creates a new note from a bullet's children, named after the bullet, in the same folder
|
||||||
// shared like the original; the bullet itself becomes a [[link]] to it. Returns the new note.
|
// and shared like the original. The original note is not changed here: the editor replaces the branch
|
||||||
func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, leases collab.LeaseView) (*domain.Document, error) {
|
// 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)
|
source, err := s.GetDocumentByID(userID, docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
state, err := s.readCollabDoc(docID)
|
state, err := s.readCollabDoc(docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
flat := state.Flatten()
|
flat := state.Flatten()
|
||||||
start := -1
|
start := -1
|
||||||
@@ -112,7 +113,7 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if start < 0 {
|
if start < 0 {
|
||||||
return nil, ErrBlockNotFound
|
return nil, "", ErrBlockNotFound
|
||||||
}
|
}
|
||||||
root := flat[start]
|
root := flat[start]
|
||||||
var children []domain.DocumentBlock
|
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)
|
ids = append(ids, flat[i].ID)
|
||||||
}
|
}
|
||||||
if foreignLeaseIn(ids, a, leases) {
|
if foreignLeaseIn(ids, a, leases) {
|
||||||
return nil, ErrBranchLocked
|
return nil, "", ErrBranchLocked
|
||||||
}
|
}
|
||||||
|
|
||||||
base, tags := branchTitle(root.Content)
|
base, tags := branchTitle(root.Content)
|
||||||
title := s.uniqueTitle(userID, base)
|
title := s.uniqueTitle(userID, base)
|
||||||
created, err := s.CreateDocument(userID, "", title, "📄", source.FolderPath, children, nil, false)
|
created, err := s.CreateDocument(userID, "", title, "📄", source.FolderPath, children, nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
s.copyShares(userID, source, created.ID)
|
s.copyShares(userID, source, created.ID)
|
||||||
|
|
||||||
link := "[[" + title + "]]"
|
link := "[[" + title + "]]"
|
||||||
if tags != "" {
|
if tags != "" {
|
||||||
link += " " + tags
|
link += " " + tags
|
||||||
}
|
}
|
||||||
_, _, err = s.applyDocChange(docID, a, leases, func(d *collab.Doc) []collab.Op {
|
return created, link, nil
|
||||||
b, ok := d.Blocks[blockID]
|
}
|
||||||
if !ok || b.Deleted {
|
|
||||||
return nil
|
// UndoExtract puts a note created by ExtractBranch into the trash (only its creator may).
|
||||||
}
|
func (s *Store) UndoExtract(userID, docID string) error {
|
||||||
ops := []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: blockID, BaseVersion: b.Version, Content: &link}}
|
doc, err := s.GetDocumentByID(userID, docID)
|
||||||
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 {
|
if err != nil {
|
||||||
_ = s.hardDeleteDocument(created.ID)
|
return err
|
||||||
return nil, 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
|
// 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