Merge remote-tracking branch 'origin/dev' into feat/gemini-workspace
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
fc7b2b3a2c
10 files changed
+161
-71
No files matched your search
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
|
// 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
|
||||||
|
|||||||
Reference in new issue
Block a user