diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 2fd0d33..c7cb5c5 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -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/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 | `/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) | | DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen | | PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) | diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 94d0256..9bda8a5 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -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. - **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`). +- **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. - **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. diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 7ec01c1..437ae2b 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -240,6 +240,9 @@ export const HelpView: React.FC = ({ onBack }) => {
  • Ansicht „Tags & Filter“: 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.
  • +
  • + Rückverweise: Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit [[Titel]] verlinken. Ein Klick auf die Fundstelle springt direkt dorthin. +
  • Papierkorb: 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.
  • diff --git a/frontend/src/features/workspace/BacklinksSection.tsx b/frontend/src/features/workspace/BacklinksSection.tsx new file mode 100644 index 0000000..611b03b --- /dev/null +++ b/frontend/src/features/workspace/BacklinksSection.tsx @@ -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(']]') ? ( + + {part} + + ) : ( + {part} + ) + )} + +); + +// "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 = ({ docId, title, onOpen }) => { + const [links, setLinks] = useState([]); + const [collapsed, setCollapsed] = useState(() => { + 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 ( +
    + + {!collapsed && ( +
      + {links.map((l) => ( +
    • + +
        + {l.blocks.map((b) => ( +
      • + +
      • + ))} +
      + {!!l.more &&

      … und {l.more} weitere Stellen

      } +
    • + ))} +
    + )} +
    + ); +}; diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 5de1a4f..480a033 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -41,12 +41,14 @@ export interface DocumentEditorProps { onOpenShareModal: (doc: Document) => void; onSelectTag: (tag: string) => void; onWikiLinkClick?: (pageTitle: string) => void; + onOpenDocBlock?: (docId: string, blockId?: string) => void; onBackMobile?: () => void; onOpenTask?: (taskId: string) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; } import { EmojiPickerModal } from './EmojiPickerModal'; +import { BacklinksSection } from './BacklinksSection'; function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] { if (!blocks || blocks.length === 0) { @@ -182,6 +184,7 @@ export const DocumentEditor: React.FC = ({ onDocMetadataChange, onDeleteDoc, onRestoreDoc, + onOpenDocBlock, onOpenShareModal, onSelectTag, onWikiLinkClick, @@ -1070,6 +1073,8 @@ export const DocumentEditor: React.FC = ({ onUndo={handleUndo} onRedo={handleRedo} /> + + {onOpenDocBlock && !inTrash && } )} diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 6b2646e..b62758f 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -1583,6 +1583,7 @@ export const WorkspaceView: React.FC = ({ onOpenShareModal={openDocShareModal} onSelectTag={handleSelectTag} onWikiLinkClick={handleWikiLinkClick} + onOpenDocBlock={handleJumpToDocBlock} onBackMobile={() => setMobileShowDetail(false)} onOpenTask={onOpenTask} onSaveStatusChange={(status, err) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d9ceb88..38a4811 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,6 +4,7 @@ import { DashboardStats, Document, TrashedDocument, + Backlink, DocumentBlock, DocShare, KanbanColumn, @@ -653,6 +654,10 @@ export const api = { await request(`/api/v1/documents/${id}`, { method: 'DELETE' }); }, + async getBacklinks(id: string): Promise { + return request(`/api/v1/documents/${id}/backlinks`); + }, + async getTrash(): Promise { return request('/api/v1/documents/trash'); }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 66fcef8..595750b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -232,6 +232,16 @@ export interface DocShare { 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). */ export interface TrashedDocument { id: string; diff --git a/internal/api/server.go b/internal/api/server.go index 44c2b62..5f082c7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -176,6 +176,7 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/documents/trash", s.handleGetTrash) r.Delete("/api/v1/documents/trash", s.handleEmptyTrash) 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.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) } +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) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") diff --git a/internal/storage/backlinks.go b/internal/storage/backlinks.go new file mode 100644 index 0000000..2eaaab1 --- /dev/null +++ b/internal/storage/backlinks.go @@ -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 +} diff --git a/internal/storage/backlinks_test.go b/internal/storage/backlinks_test.go new file mode 100644 index 0000000..e3411ab --- /dev/null +++ b/internal/storage/backlinks_test.go @@ -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") + } +}