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:
1 parent
f69f74fdc7
commit
677fe0fc0a
11 files changed
+303
No files matched your search
@@ -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`) |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user