feat(workspace): bring an extracted note back into the bullet
A bullet that is only a link to a note (as left by /extract) gets a
"Notiz zurückholen" button. POST /documents/{id}/inline returns the
linked note's blocks with fresh ids and trashes the note; the editor
puts the title back as text and inserts the blocks as an own edit, so
Strg+Z restores the link and the note. Only own notes that no other
note links to. Release 0.52.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
75aea4a7d4
commit
39a04082fc
15 files changed
+293
-11
No files matched your search
+7
-1
@@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.52.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Notizen: Ein ausgelagerter Punkt lässt sich zurückholen. Am Link, der nach `/extract` stehen bleibt, gibt es den Knopf „Notiz zurückholen“: Die Punkte der Notiz kommen wieder als Unterpunkte an diese Stelle, der Link wird wieder zum Text, und die Notiz wandert in den Papierkorb. Strg+Z oder „Rückgängig“ macht das wieder rückgängig. Geht mit eigenen Notizen, die nirgends sonst verlinkt sind.
|
||||||
|
|
||||||
## [0.51.0] - 2026-11-01
|
## [0.51.0] - 2026-11-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -786,7 +791,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...dev
|
||||||
|
[0.52.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...v0.52.0
|
||||||
[0.51.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...v0.51.0
|
[0.51.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...v0.51.0
|
||||||
[0.50.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.2...v0.50.0
|
[0.50.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.2...v0.50.0
|
||||||
[0.49.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.1...v0.49.2
|
[0.49.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.1...v0.49.2
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
|||||||
| GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) |
|
| GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) |
|
||||||
| POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) |
|
| POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) |
|
||||||
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
|
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
|
||||||
|
| POST | `/documents/{id}/inline` | `{blockId}`: Gegenstück zu `extract` für einen Punkt, der nur aus `[[Notiz]]` (ggf. mit Hashtags) besteht → `{sourceId, content, blocks}`: Text für den Punkt (Titel plus Hashtags) und die Punkte der Notiz mit neuen IDs (`level` 0 = direkt unter dem Punkt). Die verlinkte Notiz kommt in den Papierkorb; rückgängig mit `POST /documents/{sourceId}/restore`. Die Ursprungsnotiz ändert der Editor selbst. `400` kein Link-Punkt, `404` Notiz nicht gefunden, `403` fremde Notiz, `409` noch aus anderen Notizen verlinkt |
|
||||||
| 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 |
|
||||||
| 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 |
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (mit Syntax-Highlighting im VS-Code-Stil), `/callout`, `/divider`, `/extract` (Ast als eigene Notiz auslagern), `/image` (Bilder einfügen/hochladen, Aliase `/bild`, `/foto`), `/audio` (Sprachnotiz/Audiodatei, Alias `/sprachnotiz`), `/diktat` (Spracheingabe mit Live-Mitschreiben, Alias `/dictate`) (Liste in `OutlinerBlock.tsx`). Das Menü öffnet sich nur am Textanfang oder nach einem Leerzeichen und schließt mit `Esc` oder `Backspace`.
|
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (mit Syntax-Highlighting im VS-Code-Stil), `/callout`, `/divider`, `/extract` (Ast als eigene Notiz auslagern), `/image` (Bilder einfügen/hochladen, Aliase `/bild`, `/foto`), `/audio` (Sprachnotiz/Audiodatei, Alias `/sprachnotiz`), `/diktat` (Spracheingabe mit Live-Mitschreiben, Alias `/dictate`) (Liste in `OutlinerBlock.tsx`). Das Menü öffnet sich nur am Textanfang oder nach einem Leerzeichen und schließt mit `Esc` oder `Backspace`.
|
||||||
- **Link-Vorschau:** Besteht ein Bulletpoint ausschließlich aus einer einzelnen `https`-Adresse (oder wurde über `/link` in einem leeren Punkt eingefügt), zeigt MindSift darüber eine interaktive Vorschaukarte (`LinkPreviewCard`, serverseitig via `/link-preview`). ✕ blendet die Karte aus und wandelt die Adresse synchronisiert in `[Titel](url)` um.
|
- **Link-Vorschau:** Besteht ein Bulletpoint ausschließlich aus einer einzelnen `https`-Adresse (oder wurde über `/link` in einem leeren Punkt eingefügt), zeigt MindSift darüber eine interaktive Vorschaukarte (`LinkPreviewCard`, serverseitig via `/link-preview`). ✕ blendet die Karte aus und wandelt die Adresse synchronisiert in `[Titel](url)` um.
|
||||||
- **Ast auslagern (`/extract`):** Wandelt den aktuellen Punkt samt Unterpunkten in eine eigenständige Notiz im selben Ordner um. Der Server legt die Notiz an und liefert den Link zurück; der Editor ersetzt den Ast lokal über die Sync-Engine als eigene Operation (Strg+Z nimmt die Auslagerung zurück und legt die neue Notiz via `POST /documents/{id}/extract/undo` in den Papierkorb).
|
- **Ast auslagern (`/extract`):** Wandelt den aktuellen Punkt samt Unterpunkten in eine eigenständige Notiz im selben Ordner um. Der Server legt die Notiz an und liefert den Link zurück; der Editor ersetzt den Ast lokal über die Sync-Engine als eigene Operation (Strg+Z nimmt die Auslagerung zurück und legt die neue Notiz via `POST /documents/{id}/extract/undo` in den Papierkorb).
|
||||||
|
- **Notiz zurückholen:** Besteht ein Punkt nur aus einem Link auf eine Notiz (wie nach `/extract`), zeigt seine Knopfleiste „Notiz zurückholen“. `POST /documents/{id}/inline` liefert die Punkte der verlinkten Notiz und legt sie in den Papierkorb; der Editor setzt den Titel (mit den Hashtags des Links) wieder als Text ein und fügt die Punkte als Unterpunkte ein, vor eventuell schon vorhandenen. Strg+Z oder „Rückgängig“ stellt den Link wieder her und holt die Notiz aus dem Papierkorb. Nur mit eigenen Notizen, und nicht, solange andere Notizen sie noch verlinken.
|
||||||
- **Notizen zusammenführen (Merge):** Per Drag & Drop einer Notiz auf eine andere in der Seitenleiste oder über das Drei-Punkte-Menü im Notizkopf („Mit anderer Notiz zusammenführen…“). Die eigene Quellnotiz wird als `h2`-Abschnitt angehängt und in den Papierkorb verschoben; der Hinweis bietet vollständiges Rückgängigmachen (`POST /documents/{id}/merge/undo`).
|
- **Notizen zusammenführen (Merge):** Per Drag & Drop einer Notiz auf eine andere in der Seitenleiste oder über das Drei-Punkte-Menü im Notizkopf („Mit anderer Notiz zusammenführen…“). Die eigene Quellnotiz wird als `h2`-Abschnitt angehängt und in den Papierkorb verschoben; der Hinweis bietet vollständiges Rückgängigmachen (`POST /documents/{id}/merge/undo`).
|
||||||
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
|
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
|
||||||
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.51.0",
|
"version": "0.52.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.51.0",
|
"version": "0.52.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.51.0",
|
"version": "0.52.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
|
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
|
||||||
<span className="text-accent font-bold">📤</span>
|
<span className="text-accent font-bold">📤</span>
|
||||||
<span>/extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]])</span>
|
<span>/extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]]; der Knopf „Notiz zurückholen“ am Link macht das wieder rückgängig)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
|
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
|
||||||
<span className="text-accent font-bold">🎙️</span>
|
<span className="text-accent font-bold">🎙️</span>
|
||||||
|
|||||||
@@ -545,7 +545,21 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
// The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash
|
// 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 lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null);
|
||||||
|
// The last note brought back, so undoing it also takes the note out of the trash again
|
||||||
|
const lastInlineRef = useRef<{ sourceId: string; blockId: string; content: string } | null>(null);
|
||||||
const finishUndoExtract = useCallback(() => {
|
const finishUndoExtract = useCallback(() => {
|
||||||
|
const inlined = lastInlineRef.current;
|
||||||
|
if (inlined) {
|
||||||
|
const block = currentBlocksRef.current.find((b) => b.id === inlined.blockId);
|
||||||
|
if (!block || block.content !== inlined.content) {
|
||||||
|
lastInlineRef.current = null;
|
||||||
|
api
|
||||||
|
.restoreDocument(inlined.sourceId)
|
||||||
|
.then(() => api.getDocument(inlined.sourceId))
|
||||||
|
.then((restored) => onDocCreated?.(restored))
|
||||||
|
.catch((err: any) => showNotice(err?.message || 'Notiz konnte nicht wiederhergestellt werden'));
|
||||||
|
}
|
||||||
|
}
|
||||||
const info = lastExtractRef.current;
|
const info = lastExtractRef.current;
|
||||||
if (!info) return;
|
if (!info) return;
|
||||||
const block = currentBlocksRef.current.find((b) => b.id === info.blockId);
|
const block = currentBlocksRef.current.find((b) => b.id === info.blockId);
|
||||||
@@ -555,7 +569,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
.undoExtract(info.newDocId)
|
.undoExtract(info.newDocId)
|
||||||
.then(() => onDocRemoved?.(info.newDocId))
|
.then(() => onDocRemoved?.(info.newDocId))
|
||||||
.catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden'));
|
.catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden'));
|
||||||
}, [onDocRemoved, showNotice]);
|
}, [onDocCreated, onDocRemoved, showNotice]);
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
|
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -614,6 +628,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
while (end < prev.length && (prev[end].level || 0) > level) end++;
|
while (end < prev.length && (prev[end].level || 0) > level) end++;
|
||||||
applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]);
|
applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]);
|
||||||
}
|
}
|
||||||
|
lastInlineRef.current = null;
|
||||||
lastExtractRef.current = { newDocId: created.id, blockId, link };
|
lastExtractRef.current = { newDocId: created.id, blockId, link };
|
||||||
onDocCreated?.(created);
|
onDocCreated?.(created);
|
||||||
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [
|
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [
|
||||||
@@ -627,6 +642,35 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
[docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks]
|
[docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The way back: a bullet that only links to a note gets that note's bullets below it again, the note
|
||||||
|
// goes to the trash. Inserted here as an own edit like extracting, so Strg+Z brings the link back.
|
||||||
|
const handleInlineBlock = useCallback(
|
||||||
|
async (blockId: string) => {
|
||||||
|
const engine = engineRef.current;
|
||||||
|
if (!engine) return;
|
||||||
|
try {
|
||||||
|
await engine.syncNow();
|
||||||
|
const res = await api.inlineNote(docId, blockId);
|
||||||
|
(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;
|
||||||
|
const inserted = res.blocks.map((b) => ({ ...b, level: level + 1 + (b.level || 0) }));
|
||||||
|
// Above any bullets added below the link meanwhile
|
||||||
|
applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: res.content }, ...inserted, ...prev.slice(i + 1)]);
|
||||||
|
}
|
||||||
|
lastExtractRef.current = null;
|
||||||
|
lastInlineRef.current = { sourceId: res.sourceId, blockId, content: res.content };
|
||||||
|
onDocRemoved?.(res.sourceId);
|
||||||
|
showNotice('Notiz zurückgeholt, sie liegt jetzt im Papierkorb', [{ label: 'Rückgängig', run: () => handleUndo() }]);
|
||||||
|
} catch (err: any) {
|
||||||
|
showNotice(err?.message || 'Zurückholen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[docId, onDocRemoved, 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;
|
||||||
@@ -1363,6 +1407,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
levelOffset={levelOffset}
|
levelOffset={levelOffset}
|
||||||
onZoomBlock={setZoomedBlockId}
|
onZoomBlock={setZoomedBlockId}
|
||||||
onExtractBlock={canEdit ? handleExtractBlock : undefined}
|
onExtractBlock={canEdit ? handleExtractBlock : undefined}
|
||||||
|
onInlineBlock={canEdit ? handleInlineBlock : undefined}
|
||||||
onUpdateBlock={handleUpdateBlock}
|
onUpdateBlock={handleUpdateBlock}
|
||||||
onUpdateBlocks={handleUpdateBlocks}
|
onUpdateBlocks={handleUpdateBlocks}
|
||||||
onCreateBlock={handleCreateBlock}
|
onCreateBlock={handleCreateBlock}
|
||||||
@@ -1513,6 +1558,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
levelOffset={0}
|
levelOffset={0}
|
||||||
onZoomBlock={setZoomedBlockId}
|
onZoomBlock={setZoomedBlockId}
|
||||||
onExtractBlock={canEdit ? handleExtractBlock : undefined}
|
onExtractBlock={canEdit ? handleExtractBlock : undefined}
|
||||||
|
onInlineBlock={canEdit ? handleInlineBlock : undefined}
|
||||||
onUpdateBlock={handleUpdateBlock}
|
onUpdateBlock={handleUpdateBlock}
|
||||||
onUpdateBlocks={handleUpdateBlocks}
|
onUpdateBlocks={handleUpdateBlocks}
|
||||||
onCreateBlock={handleCreateBlock}
|
onCreateBlock={handleCreateBlock}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Mic,
|
Mic,
|
||||||
AudioLines,
|
AudioLines,
|
||||||
FileOutput,
|
FileOutput,
|
||||||
|
FileInput,
|
||||||
Link2,
|
Link2,
|
||||||
Type,
|
Type,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -96,6 +97,7 @@ export interface OutlinerBlockProps {
|
|||||||
onZoomBlock?: (blockId: string) => void;
|
onZoomBlock?: (blockId: string) => void;
|
||||||
/** Turns this bullet and its children into a note of its own */
|
/** Turns this bullet and its children into a note of its own */
|
||||||
onExtractBlock?: (blockId: string) => void;
|
onExtractBlock?: (blockId: string) => void;
|
||||||
|
onInlineBlock?: (blockId: string) => void;
|
||||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
|
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
|
||||||
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
|
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
|
||||||
@@ -151,6 +153,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
levelOffset = 0,
|
levelOffset = 0,
|
||||||
onZoomBlock,
|
onZoomBlock,
|
||||||
onExtractBlock,
|
onExtractBlock,
|
||||||
|
onInlineBlock,
|
||||||
onUpdateBlock,
|
onUpdateBlock,
|
||||||
onCreateBlockBelow,
|
onCreateBlockBelow,
|
||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
@@ -1724,6 +1727,19 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* The way back: a bullet that only links to a note gets that note's bullets again */}
|
||||||
|
{canEdit && onInlineBlock && isNoteLinkOnly(localContent) && !mediaPicker && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onInlineBlock(block.id)}
|
||||||
|
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||||
|
title="Notiz zurückholen (ihre Punkte kommen wieder hierher, die Notiz in den Papierkorb)"
|
||||||
|
aria-label="Notiz zurückholen"
|
||||||
|
>
|
||||||
|
<FileInput className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Zoom / Focus button */}
|
{/* Zoom / Focus button */}
|
||||||
{onZoomBlock && (
|
{onZoomBlock && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface OutlinerTreeProps {
|
|||||||
levelOffset?: number;
|
levelOffset?: number;
|
||||||
onZoomBlock?: (blockId: string) => void;
|
onZoomBlock?: (blockId: string) => void;
|
||||||
onExtractBlock?: (blockId: string) => void;
|
onExtractBlock?: (blockId: string) => void;
|
||||||
|
onInlineBlock?: (blockId: string) => void;
|
||||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
|
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
|
||||||
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
||||||
@@ -54,6 +55,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
levelOffset = 0,
|
levelOffset = 0,
|
||||||
onZoomBlock,
|
onZoomBlock,
|
||||||
onExtractBlock,
|
onExtractBlock,
|
||||||
|
onInlineBlock,
|
||||||
onUpdateBlock,
|
onUpdateBlock,
|
||||||
onUpdateBlocks,
|
onUpdateBlocks,
|
||||||
onCreateBlock,
|
onCreateBlock,
|
||||||
@@ -933,6 +935,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
levelOffset={levelOffset}
|
levelOffset={levelOffset}
|
||||||
onZoomBlock={onZoomBlock}
|
onZoomBlock={onZoomBlock}
|
||||||
onExtractBlock={onExtractBlock}
|
onExtractBlock={onExtractBlock}
|
||||||
|
onInlineBlock={onInlineBlock}
|
||||||
onUpdateBlock={onUpdateBlock}
|
onUpdateBlock={onUpdateBlock}
|
||||||
onCreateBlockBelow={handleCreateBlockBelow}
|
onCreateBlockBelow={handleCreateBlockBelow}
|
||||||
onPasteMultiLines={handlePasteMultiLines}
|
onPasteMultiLines={handlePasteMultiLines}
|
||||||
|
|||||||
@@ -799,6 +799,11 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** The way back from extractBranch: the linked note's blocks for the bullet; the note goes to the trash. */
|
||||||
|
async inlineNote(docId: string, blockId: string): Promise<{ sourceId: string; content: string; blocks: DocumentBlock[] }> {
|
||||||
|
return request(`/api/v1/documents/${docId}/inline`, { method: 'POST', body: JSON.stringify({ blockId }) });
|
||||||
|
},
|
||||||
|
|
||||||
/** Puts a note created by extractBranch into the trash again. */
|
/** Puts a note created by extractBranch into the trash again. */
|
||||||
async undoExtract(newDocId: string): Promise<void> {
|
async undoExtract(newDocId: string): Promise<void> {
|
||||||
await request(`/api/v1/documents/${newDocId}/extract/undo`, { method: 'POST' });
|
await request(`/api/v1/documents/${newDocId}/extract/undo`, { method: 'POST' });
|
||||||
|
|||||||
@@ -24,14 +24,16 @@ func (s *Server) restructureActor(r *http.Request, clientID string) collab.Actor
|
|||||||
}
|
}
|
||||||
|
|
||||||
func restructureStatus(err error) int {
|
func restructureStatus(err error) int {
|
||||||
|
var stillLinked *storage.StillLinkedError
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, storage.ErrBranchLocked):
|
case errors.Is(err, storage.ErrBranchLocked), errors.As(err, &stillLinked):
|
||||||
return http.StatusConflict
|
return http.StatusConflict
|
||||||
case errors.Is(err, storage.ErrBlockNotFound), errors.Is(err, storage.ErrDocNotFound):
|
case errors.Is(err, storage.ErrBlockNotFound), errors.Is(err, storage.ErrDocNotFound), errors.Is(err, storage.ErrLinkedMissing):
|
||||||
return http.StatusNotFound
|
return http.StatusNotFound
|
||||||
case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash), errors.Is(err, storage.ErrAlreadyNote):
|
case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash), errors.Is(err, storage.ErrAlreadyNote),
|
||||||
|
errors.Is(err, storage.ErrNotNoteLink):
|
||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
case errors.Is(err, storage.ErrMergeNotOwner):
|
case errors.Is(err, storage.ErrMergeNotOwner), errors.Is(err, storage.ErrInlineOwner):
|
||||||
return http.StatusForbidden
|
return http.StatusForbidden
|
||||||
}
|
}
|
||||||
return http.StatusInternalServerError
|
return http.StatusInternalServerError
|
||||||
@@ -80,6 +82,34 @@ func (s *Server) handleUndoExtract(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]bool{"undone": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"undone": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleInlineNote: POST /documents/{id}/inline {blockId} – the way back from extracting: returns the
|
||||||
|
// linked note's blocks for the bullet and puts that note into the trash (undo: POST /documents/{note}/restore).
|
||||||
|
func (s *Server) handleInlineNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
if !storage.CanEditDoc(s.store.DocRole(userID, docID)) {
|
||||||
|
writeError(w, http.StatusForbidden, "Keine Schreibrechte für diese Notiz")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
BlockID string `json:"blockId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.BlockID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "blockId fehlt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.store.InlineNote(userID, docID, req.BlockID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, restructureStatus(err), err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, _ := s.store.GetAuthorizedDocUsers(res.SourceID)
|
||||||
|
for _, uid := range users {
|
||||||
|
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": res.SourceID})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
func (s *Server) handleMergeDocuments(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleMergeDocuments(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
|
|||||||
@@ -186,3 +186,79 @@ func TestMergeDocumentsAndUndo(t *testing.T) {
|
|||||||
t.Fatalf("foreign source: want 403, got %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("foreign source: want 403, got %d %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The way back from extracting: the link bullet gets the note's blocks, the note goes to the trash.
|
||||||
|
func TestInlineNoteBringsBranchBack(t *testing.T) {
|
||||||
|
srv, store, token := setupTestServer(t)
|
||||||
|
router := srv.Router()
|
||||||
|
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||||
|
uid := claims.UserID
|
||||||
|
note, _ := store.CreateDocument(uid, "", "Konzept: Garten", "", "", []domain.DocumentBlock{
|
||||||
|
{ID: "blk_b", Type: "bullet", Content: "Beete", Level: 0, Position: 1},
|
||||||
|
{ID: "blk_c", Type: "todo", Content: "Erde kaufen", Level: 1, Position: 2, Checked: true},
|
||||||
|
{ID: "blk_d", Type: "bullet", Content: "Zaun", Level: 0, Position: 3},
|
||||||
|
}, nil, false)
|
||||||
|
src, _ := store.CreateDocument(uid, "", "Projekt", "", "", []domain.DocumentBlock{
|
||||||
|
{ID: "blk_l", Type: "bullet", Content: "[[konzept: garten]] #draußen", Level: 0, Position: 1},
|
||||||
|
{ID: "blk_t", Type: "bullet", Content: "Siehe [[Konzept: Garten]]", Level: 0, Position: 2},
|
||||||
|
{ID: "blk_x", Type: "bullet", Content: "[[Gibt es nicht]]", Level: 0, Position: 3},
|
||||||
|
}, nil, false)
|
||||||
|
inline := func(docID, blockID, tok string) (int, storage.InlineResult) {
|
||||||
|
rec := sessionCall(router, "POST", "/api/v1/documents/"+docID+"/inline", tok, `{"blockId":"`+blockID+`"}`, "")
|
||||||
|
var out storage.InlineResult
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||||
|
return rec.Code, out
|
||||||
|
}
|
||||||
|
if code, _ := inline(src.ID, "blk_t", token); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("text with a link is no link bullet: %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := inline(src.ID, "blk_x", token); code != http.StatusNotFound {
|
||||||
|
t.Fatalf("missing note: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked from another note as well: refused, nothing changes
|
||||||
|
other, _ := store.CreateDocument(uid, "", "Anderswo", "", "", []domain.DocumentBlock{
|
||||||
|
{ID: "blk_o", Type: "bullet", Content: "auch [[Konzept: Garten]]", Level: 0, Position: 1},
|
||||||
|
}, nil, false)
|
||||||
|
if code, _ := inline(src.ID, "blk_l", token); code != http.StatusConflict {
|
||||||
|
t.Fatalf("note linked elsewhere must be refused: %d", code)
|
||||||
|
}
|
||||||
|
if d, _ := store.GetDocumentByID(uid, note.ID); d.IsArchived {
|
||||||
|
t.Fatal("refused inline must not trash the note")
|
||||||
|
}
|
||||||
|
if rec := sessionCall(router, "DELETE", "/api/v1/documents/"+other.ID, token, "", ""); rec.Code >= 300 {
|
||||||
|
t.Fatalf("delete other: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another person's note cannot be pulled in, even when shared with write access
|
||||||
|
stranger, strangerToken := newTestUser(t, store, "fremd3@example.com")
|
||||||
|
openSharing(t, store)
|
||||||
|
if _, err := store.CreateDocShare(uid, src.ID, stranger.Email, "write"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if code, _ := inline(src.ID, "blk_l", strangerToken); code != http.StatusForbidden && code != http.StatusNotFound {
|
||||||
|
t.Fatalf("stranger must not pull in someone else's note: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, res := inline(src.ID, "blk_l", token)
|
||||||
|
if code != http.StatusOK || res.SourceID != note.ID || res.Content != "Konzept: Garten #draußen" {
|
||||||
|
t.Fatalf("inline: %d %+v", code, res)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, b := range res.Blocks {
|
||||||
|
got = append(got, strings.Repeat(">", b.Level)+b.Content)
|
||||||
|
if b.ID == "blk_b" || b.ID == "blk_c" {
|
||||||
|
t.Fatal("blocks need fresh ids, the old ones live on in the trashed note")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(got, "|") != "Beete|>Erde kaufen|Zaun" || !res.Blocks[1].Checked || res.Blocks[1].Type != "todo" {
|
||||||
|
t.Fatalf("blocks: %v %+v", got, res.Blocks)
|
||||||
|
}
|
||||||
|
if d, _ := store.GetDocumentByID(uid, note.ID); d == nil || !d.IsArchived {
|
||||||
|
t.Fatal("brought-back note must be in the trash")
|
||||||
|
}
|
||||||
|
// Undo uses the ordinary restore
|
||||||
|
if rec := sessionCall(router, "POST", "/api/v1/documents/"+note.ID+"/restore", token, "", ""); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("restore: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,6 +173,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/api/v1/tasks/{id}/watch", s.handleWatchTask)
|
r.Post("/api/v1/tasks/{id}/watch", s.handleWatchTask)
|
||||||
r.Delete("/api/v1/tasks/{id}/watch", s.handleWatchTask)
|
r.Delete("/api/v1/tasks/{id}/watch", s.handleWatchTask)
|
||||||
r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract)
|
r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract)
|
||||||
|
r.Post("/api/v1/documents/{id}/inline", s.handleInlineNote)
|
||||||
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))
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ var (
|
|||||||
ErrMergeSelf = errors.New("Eine Notiz kann nicht mit sich selbst zusammengeführt werden")
|
ErrMergeSelf = errors.New("Eine Notiz kann nicht mit sich selbst zusammengeführt werden")
|
||||||
ErrAlreadyNote = errors.New("Dieser Punkt verweist schon auf eine eigene Notiz")
|
ErrAlreadyNote = errors.New("Dieser Punkt verweist schon auf eine eigene Notiz")
|
||||||
ErrMergeNotOwner = errors.New("Zusammenführen geht nur mit eigenen Notizen als Quelle")
|
ErrMergeNotOwner = errors.New("Zusammenführen geht nur mit eigenen Notizen als Quelle")
|
||||||
|
ErrNotNoteLink = errors.New("Dieser Punkt ist kein Link auf eine Notiz")
|
||||||
|
ErrLinkedMissing = errors.New("Die verlinkte Notiz gibt es nicht (mehr)")
|
||||||
|
ErrInlineOwner = errors.New("Zurückholen geht nur mit eigenen Notizen")
|
||||||
)
|
)
|
||||||
|
|
||||||
// readCollabDoc loads the current state of a note outside of a change.
|
// readCollabDoc loads the current state of a note outside of a change.
|
||||||
@@ -161,6 +164,100 @@ func (s *Store) UndoExtract(userID, docID string) error {
|
|||||||
return s.trashDocument(userID, docID)
|
return s.trashDocument(userID, docID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StillLinkedError: the note to bring back is linked from other notes as well
|
||||||
|
type StillLinkedError struct {
|
||||||
|
Title string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *StillLinkedError) Error() string {
|
||||||
|
return fmt.Sprintf("„%s“ ist noch in %d weiteren Notiz(en) verlinkt; diese Links würden ins Leere zeigen. Bitte dort zuerst entfernen.", e.Title, e.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InlineResult: what the editor puts back in place of the link bullet
|
||||||
|
type InlineResult struct {
|
||||||
|
SourceID string `json:"sourceId"`
|
||||||
|
// Text of the bullet: the note's title, hashtags of the link kept
|
||||||
|
Content string `json:"content"`
|
||||||
|
// The note's blocks with fresh ids; level 0 = directly below the bullet
|
||||||
|
Blocks []domain.DocumentBlock `json:"blocks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InlineNote is the way back from ExtractBranch: for a bullet that is only a link to an own note, it
|
||||||
|
// returns that note's blocks and puts the note into the trash. Like ExtractBranch it does not change
|
||||||
|
// the note with the bullet; the editor inserts the blocks itself as an own edit (undo works). Refused
|
||||||
|
// while other notes link to the note as well, since those links would point to nothing.
|
||||||
|
func (s *Store) InlineNote(userID, docID, blockID string) (*InlineResult, error) {
|
||||||
|
state, err := s.readCollabDoc(docID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var content string
|
||||||
|
found := false
|
||||||
|
for _, b := range state.Flatten() {
|
||||||
|
if b.ID == blockID {
|
||||||
|
content, found = b.Content, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, ErrBlockNotFound
|
||||||
|
}
|
||||||
|
m := wikiLinkRe.FindStringSubmatch(content)
|
||||||
|
if !noteLinkOnlyRe.MatchString(content) || m == nil {
|
||||||
|
return nil, ErrNotNoteLink
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(m[1])
|
||||||
|
docs, err := s.GetDocuments(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var linked *domain.Document
|
||||||
|
for i := range docs {
|
||||||
|
d := &docs[i]
|
||||||
|
if d.ID == docID || !strings.EqualFold(strings.TrimSpace(d.Title), title) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// An own note wins over one shared with the user under the same title
|
||||||
|
if linked == nil || (d.IsOwner && !linked.IsOwner) {
|
||||||
|
linked = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if linked == nil {
|
||||||
|
return nil, ErrLinkedMissing
|
||||||
|
}
|
||||||
|
if !linked.IsOwner {
|
||||||
|
return nil, ErrInlineOwner
|
||||||
|
}
|
||||||
|
backlinks, err := s.GetBacklinks(userID, linked.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
others := 0
|
||||||
|
for _, bl := range backlinks {
|
||||||
|
if bl.DocID != docID {
|
||||||
|
others++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if others > 0 {
|
||||||
|
return nil, &StillLinkedError{Title: linked.Title, Count: others}
|
||||||
|
}
|
||||||
|
source, err := s.readCollabDoc(linked.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
blocks := []domain.DocumentBlock{}
|
||||||
|
for _, b := range source.Flatten() {
|
||||||
|
blocks = append(blocks, toDocumentBlock(b, b.Level, len(blocks)))
|
||||||
|
}
|
||||||
|
rest := strings.TrimSpace(wikiLinkRe.ReplaceAllString(content, ""))
|
||||||
|
text := strings.TrimSpace(linked.Title + " " + rest)
|
||||||
|
if err := s.trashDocument(userID, linked.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &InlineResult{SourceID: linked.ID, Content: text, Blocks: blocks}, 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
|
||||||
// source, that owner may edit the new note as well (the branch was theirs to read before).
|
// source, that owner may edit the new note as well (the branch was theirs to read before).
|
||||||
func (s *Store) copyShares(userID string, source *domain.Document, newDocID string) {
|
func (s *Store) copyShares(userID string, source *domain.Document, newDocID string) {
|
||||||
|
|||||||
Reference in new issue
Block a user