feat(workspace): backlinks at the end of a note

GET /documents/{id}/backlinks lists the notes the user can open whose
blocks link [[Title]] (also |alias and #section, case-insensitive),
excluding the note itself and the trash. The collapsible "Verlinkt auf
diese Notiz" section shows each note with folder and the linking bullets;
a click jumps straight to the bullet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 08:00:57 +01:00
1 parent f69f74fdc7
commit 677fe0fc0a
11 files changed
+303

No files matched your search

+1
View File
@@ -106,6 +106,7 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) | | GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen = in den Papierkorb (Eigentümer) bzw. Freigabe verlassen (andere) | | GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen = in den Papierkorb (Eigentümer) bzw. Freigabe verlassen (andere) |
| GET/DELETE | `/documents/trash` | Papierkorb auflisten (`id`, `title`, `icon`, `folderPath`, `deletedAt`, `isOwner`) bzw. leeren | | GET/DELETE | `/documents/trash` | Papierkorb auflisten (`id`, `title`, `icon`, `folderPath`, `deletedAt`, `isOwner`) bzw. leeren |
| GET | `/documents/{id}/backlinks` | Notizen mit `[[Titel]]`-Link auf diese Notiz (nur sichtbare, ohne Papierkorb): `docId`, `title`, `icon`, `folderPath`, bis zu 5 `blocks` (`id`, `content`), `more` |
| POST | `/documents/{id}/restore` | Aus dem Papierkorb zurückholen (fehlender Ordner wird neu angelegt) | | POST | `/documents/{id}/restore` | Aus dem Papierkorb zurückholen (fehlender Ordner wird neu angelegt) |
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen | | DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) | | PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
+1
View File
@@ -16,6 +16,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück. - **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt. - **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`). - **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
- **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.
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code. - **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
- **Speichern:** Jede Notiz hat im Browser eine `DocSyncEngine` ([lib/docSync/](../../frontend/src/lib/docSync/)). Der Editor meldet jede Änderung der Block-Liste; die Engine übersetzt sie in Operationen, speichert sie zuerst in IndexedDB und sendet sie dann gesammelt (300 ms) an `POST /docs/{id}/sync`. Beim Öffnen zeigt sie sofort den gespeicherten Stand, auch offline. - **Speichern:** Jede Notiz hat im Browser eine `DocSyncEngine` ([lib/docSync/](../../frontend/src/lib/docSync/)). Der Editor meldet jede Änderung der Block-Liste; die Engine übersetzt sie in Operationen, speichert sie zuerst in IndexedDB und sendet sie dann gesammelt (300 ms) an `POST /docs/{id}/sync`. Beim Öffnen zeigt sie sofort den gespeicherten Stand, auch offline.
+3
View File
@@ -240,6 +240,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<li> <li>
<strong className="text-main">Ansicht „Tags & Filter“:</strong> Im oberen Workspace-Reiter findest du die Tag-Übersicht. Hier siehst du alle verwendeten Hashtags, kannst Fundstellen aus allen Dokumenten gebündelt durchsuchen und sogar eigene freie Notizen auf Tag-Seiten hinterlegen. <strong className="text-main">Ansicht „Tags & Filter“:</strong> Im oberen Workspace-Reiter findest du die Tag-Übersicht. Hier siehst du alle verwendeten Hashtags, kannst Fundstellen aus allen Dokumenten gebündelt durchsuchen und sogar eigene freie Notizen auf Tag-Seiten hinterlegen.
</li> </li>
<li>
<strong className="text-main">Rückverweise:</strong> Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">[[Titel]]</code> verlinken. Ein Klick auf die Fundstelle springt direkt dorthin.
</li>
<li> <li>
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war. <strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war.
</li> </li>
@@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, Link2 } from 'lucide-react';
import { Backlink } from '../../lib/types';
import { api } from '../../lib/api';
interface BacklinksSectionProps {
docId: string;
title: string;
onOpen: (docId: string, blockId?: string) => void;
}
const COLLAPSED_KEY = 'mindsift:backlinks_collapsed';
// Shows the [[Wiki-Link]] parts of a block in the accent color, the rest as plain text
const Snippet: React.FC<{ text: string }> = ({ text }) => (
<>
{text.split(/(\[\[[^\]]*\]\])/g).map((part, i) =>
part.startsWith('[[') && part.endsWith(']]') ? (
<span key={i} className="text-accent font-medium">
{part}
</span>
) : (
<React.Fragment key={i}>{part}</React.Fragment>
)
)}
</>
);
// "Verlinkt auf diese Notiz" at the end of a note. Hidden while nothing links here; reloads shortly
// after the title changes, since links are matched by title.
export const BacklinksSection: React.FC<BacklinksSectionProps> = ({ docId, title, onOpen }) => {
const [links, setLinks] = useState<Backlink[]>([]);
const [collapsed, setCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem(COLLAPSED_KEY) === 'true';
} catch {
return false;
}
});
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
api
.getBacklinks(docId)
.then((res) => !cancelled && setLinks(res))
.catch(() => !cancelled && setLinks([]));
}, 400);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [docId, title]);
if (links.length === 0) return null;
const toggle = () => {
setCollapsed((prev) => {
try {
localStorage.setItem(COLLAPSED_KEY, String(!prev));
} catch {}
return !prev;
});
};
return (
<section className="mt-10 pt-4 border-t border-border" aria-label="Rückverweise">
<button
type="button"
onClick={toggle}
aria-expanded={!collapsed}
className="w-full flex items-center gap-1.5 min-h-[44px] sm:min-h-0 py-1 text-xs font-semibold text-muted hover:text-main cursor-pointer"
>
{collapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
<Link2 className="w-3.5 h-3.5 text-accent" />
<span>Verlinkt auf diese Notiz ({links.length})</span>
</button>
{!collapsed && (
<ul className="mt-2 space-y-2">
{links.map((l) => (
<li key={l.docId} className="p-3 bg-panel border border-border rounded-card">
<button
type="button"
onClick={() => onOpen(l.docId)}
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
>
<span className="shrink-0">{l.icon || '📝'}</span>
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
{l.title || 'Unbenannte Notiz'}
</span>
{l.folderPath && (
<span className="hidden sm:inline-flex items-center gap-1 text-[11px] text-faint min-w-0">
<Folder className="w-3 h-3 shrink-0" />
<span className="truncate">{l.folderPath}</span>
</span>
)}
</button>
<ul className="mt-1.5 space-y-1">
{l.blocks.map((b) => (
<li key={b.id}>
<button
type="button"
onClick={() => onOpen(l.docId, b.id)}
className="w-full text-left text-xs text-muted hover:text-main px-2 py-1.5 rounded-btn hover:bg-panel-elevated border-l-2 border-border line-clamp-3 break-words cursor-pointer"
>
<Snippet text={b.content} />
</button>
</li>
))}
</ul>
{!!l.more && <p className="mt-1 text-[11px] text-faint">… und {l.more} weitere Stellen</p>}
</li>
))}
</ul>
)}
</section>
);
};
@@ -41,12 +41,14 @@ export interface DocumentEditorProps {
onOpenShareModal: (doc: Document) => void; onOpenShareModal: (doc: Document) => void;
onSelectTag: (tag: string) => void; onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void; onWikiLinkClick?: (pageTitle: string) => void;
onOpenDocBlock?: (docId: string, blockId?: string) => void;
onBackMobile?: () => void; onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void; onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
} }
import { EmojiPickerModal } from './EmojiPickerModal'; import { EmojiPickerModal } from './EmojiPickerModal';
import { BacklinksSection } from './BacklinksSection';
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] { function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
if (!blocks || blocks.length === 0) { if (!blocks || blocks.length === 0) {
@@ -182,6 +184,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onDocMetadataChange, onDocMetadataChange,
onDeleteDoc, onDeleteDoc,
onRestoreDoc, onRestoreDoc,
onOpenDocBlock,
onOpenShareModal, onOpenShareModal,
onSelectTag, onSelectTag,
onWikiLinkClick, onWikiLinkClick,
@@ -1070,6 +1073,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onUndo={handleUndo} onUndo={handleUndo}
onRedo={handleRedo} onRedo={handleRedo}
/> />
{onOpenDocBlock && !inTrash && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
</> </>
)} )}
</div> </div>
@@ -1583,6 +1583,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onOpenShareModal={openDocShareModal} onOpenShareModal={openDocShareModal}
onSelectTag={handleSelectTag} onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick} onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onBackMobile={() => setMobileShowDetail(false)} onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => { onSaveStatusChange={(status, err) => {
+5
View File
@@ -4,6 +4,7 @@ import {
DashboardStats, DashboardStats,
Document, Document,
TrashedDocument, TrashedDocument,
Backlink,
DocumentBlock, DocumentBlock,
DocShare, DocShare,
KanbanColumn, KanbanColumn,
@@ -653,6 +654,10 @@ export const api = {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' }); await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
}, },
async getBacklinks(id: string): Promise<Backlink[]> {
return request<Backlink[]>(`/api/v1/documents/${id}/backlinks`);
},
async getTrash(): Promise<TrashedDocument[]> { async getTrash(): Promise<TrashedDocument[]> {
return request<TrashedDocument[]>('/api/v1/documents/trash'); return request<TrashedDocument[]>('/api/v1/documents/trash');
}, },
+10
View File
@@ -232,6 +232,16 @@ export interface DocShare {
createdAt: string; createdAt: string;
} }
/** A note linking to the current one via [[Title]] (GET /documents/{id}/backlinks). */
export interface Backlink {
docId: string;
title: string;
icon: string;
folderPath: string;
blocks: { id: string; content: string }[];
more?: number;
}
/** A note in the trash (GET /documents/trash). */ /** A note in the trash (GET /documents/trash). */
export interface TrashedDocument { export interface TrashedDocument {
id: string; id: string;
+10
View File
@@ -176,6 +176,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/documents/trash", s.handleGetTrash) r.Get("/api/v1/documents/trash", s.handleGetTrash)
r.Delete("/api/v1/documents/trash", s.handleEmptyTrash) r.Delete("/api/v1/documents/trash", s.handleEmptyTrash)
r.Post("/api/v1/documents/{id}/restore", s.handleRestoreDocument) r.Post("/api/v1/documents/{id}/restore", s.handleRestoreDocument)
r.Get("/api/v1/documents/{id}/backlinks", s.handleGetBacklinks)
r.Delete("/api/v1/documents/{id}/permanent", s.handlePurgeDocument) r.Delete("/api/v1/documents/{id}/permanent", s.handlePurgeDocument)
r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask) r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask)
@@ -1117,6 +1118,15 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, doc) writeJSON(w, http.StatusOK, doc)
} }
func (s *Server) handleGetBacklinks(w http.ResponseWriter, r *http.Request) {
links, err := s.store.GetBacklinks(auth.GetUserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
writeJSON(w, http.StatusOK, links)
}
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id") docID := chi.URLParam(r, "id")
+101
View File
@@ -0,0 +1,101 @@
package storage
import (
"errors"
"regexp"
"sort"
"strings"
)
// Backlinks ("Verlinkt auf diese Notiz") lists the notes whose blocks contain a wiki link [[Title]]
// to a note. Matching ignores case and surrounding spaces like the link click in the editor;
// [[Title|Text]] and [[Title#Abschnitt]] count as well. Only notes the user can open are listed,
// never the note itself or notes in the trash.
// ErrNoAccess is returned when the user cannot open the note.
var ErrNoAccess = errors.New("Kein Zugriff auf diese Notiz")
var wikiLinkRe = regexp.MustCompile(`\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]`)
const maxBacklinkBlocks = 5
type BacklinkBlock struct {
ID string `json:"id"`
Content string `json:"content"`
}
type Backlink struct {
DocID string `json:"docId"`
Title string `json:"title"`
Icon string `json:"icon"`
FolderPath string `json:"folderPath"`
Blocks []BacklinkBlock `json:"blocks"`
More int `json:"more,omitempty"` // further matching blocks not listed
}
func linksTo(content, title string) bool {
for _, m := range wikiLinkRe.FindAllStringSubmatch(content, -1) {
if strings.EqualFold(strings.TrimSpace(m[1]), title) {
return true
}
}
return false
}
func (s *Store) GetBacklinks(userID, docID string) ([]Backlink, error) {
if s.DocRole(userID, docID) == "" {
return nil, ErrNoAccess
}
var title string
if err := s.db.QueryRow(`SELECT title FROM documents WHERE id = ?`, docID).Scan(&title); err != nil {
return nil, err
}
title = strings.TrimSpace(title)
out := []Backlink{}
if title == "" {
return out, nil
}
// The tree of the user: what they can open, with their own folder paths
docs, err := s.GetDocuments(userID)
if err != nil {
return nil, err
}
byID := make(map[string]int, len(docs))
for i, d := range docs {
byID[d.ID] = i
}
rows, err := s.db.Query(`SELECT id, document_id, content FROM blocks
WHERE deleted = 0 AND document_id != ? AND content LIKE '%[[%'
ORDER BY document_id, order_key`, docID)
if err != nil {
return nil, err
}
defer rows.Close()
index := map[string]int{}
for rows.Next() {
var blockID, sourceID, content string
if rows.Scan(&blockID, &sourceID, &content) != nil {
continue
}
di, visible := byID[sourceID]
if !visible || !linksTo(content, title) {
continue
}
pos, seen := index[sourceID]
if !seen {
d := docs[di]
out = append(out, Backlink{DocID: d.ID, Title: d.Title, Icon: d.Icon, FolderPath: d.FolderPath})
pos = len(out) - 1
index[sourceID] = pos
}
if len(out[pos].Blocks) < maxBacklinkBlocks {
out[pos].Blocks = append(out[pos].Blocks, BacklinkBlock{ID: blockID, Content: content})
} else {
out[pos].More++
}
}
sort.SliceStable(out, func(i, j int) bool { return strings.ToLower(out[i].Title) < strings.ToLower(out[j].Title) })
return out, nil
}
+48
View File
@@ -0,0 +1,48 @@
package storage
import (
"path/filepath"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func TestBacklinks(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "backlinks.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
openSharing(t, store)
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
block := func(id, content string) []domain.DocumentBlock {
return []domain.DocumentBlock{{ID: id, Type: "bullet", Content: content, Level: 0}}
}
target, _ := store.CreateDocument(anna.ID, "", "Homelab", "🖥️", "", block("t1", "Server [[Homelab]] selbst"), nil, false)
_, _ = store.CreateDocument(anna.ID, "", "Einkauf", "🛒", "Privat", block("a1", "Kabel für [[ homelab |das Labor]] kaufen"), nil, false)
_, _ = store.CreateDocument(anna.ID, "", "Ohne", "", "", block("a2", "Homelab ohne Klammern, [[Homelabor]]"), nil, false)
trashed, _ := store.CreateDocument(anna.ID, "", "Alt", "", "", block("a3", "[[Homelab#Netz]]"), nil, false)
_ = store.DeleteDocument(anna.ID, trashed.ID)
bens, _ := store.CreateDocument(ben.ID, "", "Bens Plan", "", "", block("b1", "siehe [[Homelab]]"), nil, false)
links, err := store.GetBacklinks(anna.ID, target.ID)
if err != nil {
t.Fatalf("GetBacklinks: %v", err)
}
if len(links) != 1 || links[0].Title != "Einkauf" || links[0].FolderPath != "Privat" || len(links[0].Blocks) != 1 || links[0].Blocks[0].ID != "a1" {
t.Fatalf("expected only Einkauf (alias link), not itself, plain text, other titles, trash or Ben's private note: %+v", links)
}
// Ben's note counts once Anna can see it
if _, err := store.CreateDocShare(ben.ID, bens.ID, anna.ID, "read"); err != nil {
t.Fatalf("share: %v", err)
}
if links, _ := store.GetBacklinks(anna.ID, target.ID); len(links) != 2 {
t.Errorf("shared note should appear: %+v", links)
}
if _, err := store.GetBacklinks(ben.ID, target.ID); err == nil {
t.Errorf("Ben cannot open Homelab, so no backlinks for him")
}
}