Merge remote-tracking branch 'origin/dev' into feat/gemini-workspace

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
sandrodach committed 2026-10-30 09:33:23 +01:00
commit fc7b2b3a2c
10 files changed
+161 -71

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- `/diktat` in Notizen: schreibt live mit (mehrzeiliger Text über den Wellen mit Zeitanzeige `mm:ss`), bricht im Browser-Modus bei Sprechpausen nicht mehr ab und schickt im Server-Modus alle ~8 Sekunden bzw. nach kurzen Sprechpausen Abschnitte zur Erkennung vorab. - `/diktat` in Notizen: schreibt live mit (mehrzeiliger Text über den Wellen mit Zeitanzeige `mm:ss`), bricht im Browser-Modus bei Sprechpausen nicht mehr ab und schickt im Server-Modus alle ~8 Sekunden bzw. nach kurzen Sprechpausen Abschnitte zur Erkennung vorab.
### Fixed ### 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.
- Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext. - Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext.
- Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit. - Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit.
- Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an. - Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an.
+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` | 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 |
+1 -1
View File
@@ -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) => {
+8 -3
View File
@@ -723,14 +723,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`, {
+17 -3
View File
@@ -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.
+27 -10
View File
@@ -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())
} }
+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.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))
+23 -23
View File
@@ -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 {
// 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 return nil
} }
ops := []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: blockID, BaseVersion: b.Version, Content: &link}} return s.trashDocument(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 {
_ = s.hardDeleteDocument(created.ID)
return nil, err
}
return created, nil
} }
// 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