munin/internal/store/entries.go
2026-10-19 10:13:56 +02:00

894 lines
30 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
)
// EntryWrite beschreibt einen Schreibvorgang auf einem Eintrag.
//
// Ein eigener Typ statt vieler Parameter: Die Anzahl der Felder wächst mit der
// Zeit, und ein benanntes Feld am Aufrufort ist beim Lesen sofort verständlich —
// anders als das fünfte Argument einer langen Parameterliste.
type EntryWrite struct {
ProjectID string
Slug string
Title string
Content string
Tags []string
Source string // SourceHuman oder SourceAgent
Author string // Name des verwendeten API-Keys
Curated bool
// ExpectedVersion aktiviert das optimistische Sperren: Ist der Wert größer
// als 0 und stimmt nicht mit dem gespeicherten Stand überein, schlägt der
// Schreibvorgang mit ErrConflict fehl.
//
// Damit können zwei Agenten, die denselben Eintrag bearbeiten, sich nicht
// mehr gegenseitig stillschweigend überschreiben — der zweite erfährt, dass
// sich etwas geändert hat, und kann neu lesen.
ExpectedVersion int
// CreatedAt und UpdatedAt geben beim ANLEGEN eines Eintrags die
// Zeitstempel vor. Leer bedeutet „jetzt“, und das ist der Normalfall.
//
// Gebraucht wird das nur beim Import (internal/importer): Wer eine
// Sicherung einspielt, will das ursprüngliche Änderungsdatum behalten.
// Sonst sähe jeder wiederhergestellte Eintrag brandneu aus, und der
// Vermerk für veraltetes Wissen (Entry.Stale) liefe ins Leere.
//
// Beim Ändern eines vorhandenen Eintrags werden beide Felder ignoriert.
// Eine Änderung passiert jetzt, und der Verlauf soll das ehrlich zeigen.
CreatedAt time.Time
UpdatedAt time.Time
}
// notInTrash gehört in JEDE lesende Abfrage auf entries (siehe
// migrations/005_trash.sql). Als Konstante, damit sich alle Stellen mit
// einer Suche finden lassen — und damit eine neue Abfrage ohne diese Bedingung
// beim Lesen auffällt. TestTrashInvisible (trash_test.go) prüft
// jeden lesenden Weg.
//
// Wo die Tabelle in einer Verknüpfung den Kurznamen "e" trägt, steht die
// Bedingung ausgeschrieben als "e.deleted_at IS NULL".
const notInTrash = `deleted_at IS NULL`
// entryColumns steht in jeder Eintrags-Abfrage.
const entryColumns = `id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at`
// scanEntry liest eine Eintragszeile. Die Schlagworte kommen aus einer eigenen
// Tabelle und werden separat nachgeladen (siehe attachTags).
func scanEntry(scan func(...any) error) (Entry, error) {
var e Entry
var curated int
var createdAt, updatedAt string
if err := scan(&e.ID, &e.ProjectID, &e.Slug, &e.Title, &e.Content,
&e.Source, &e.Author, &curated, &e.Version, &createdAt, &updatedAt); err != nil {
return Entry{}, err
}
e.Curated = curated == 1
e.CreatedAt = parseTime(createdAt)
e.UpdatedAt = parseTime(updatedAt)
return e, nil
}
// NormalizeTags bringt Schlagworte auf eine einheitliche Form: klein, ohne
// Leerzeichen am Rand, ohne Dubletten, sortiert.
//
// Ohne das hätte man "Deployment", "deployment" und " deployment " als drei
// verschiedene Schlagworte in der Datenbank — und keine Filterung würde je
// vollständig treffen.
func NormalizeTags(tags []string) []string {
seen := make(map[string]bool, len(tags))
var clean []string
for _, t := range tags {
t = strings.ToLower(strings.TrimSpace(t))
if t == "" || seen[t] {
continue
}
seen[t] = true
clean = append(clean, t)
}
sort.Strings(clean)
return clean
}
// PutEntry legt einen Eintrag an oder aktualisiert ihn — der Slug entscheidet.
//
// Genau das macht den Slug zum Werkzeug gegen Dubletten: Ein Agent, der zum
// zweiten Mal etwas über dasselbe Thema ablegt, verwendet denselben Slug und
// aktualisiert damit, statt einen Widerspruch daneben zu stellen.
//
// Alles läuft in einer Transaktion: Eintrag, Schlagworte und Verweise sind
// entweder vollständig geschrieben oder gar nicht.
func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
if !ValidSlug(w.Slug) {
return Entry{}, fmt.Errorf("ungültiger Bezeichner %q — erlaubt sind a-z, 0-9, '-' und '_'", w.Slug)
}
if strings.TrimSpace(w.Title) == "" {
return Entry{}, errors.New("der Eintrag braucht einen Titel")
}
if w.Source != SourceHuman && w.Source != SourceAgent {
return Entry{}, fmt.Errorf("unbekannte Herkunft %q", w.Source)
}
tags := NormalizeTags(w.Tags)
links := ExtractLinks(w.Content)
now := NowString()
var result Entry
err := s.tx(ctx, func(tx *transaction) error {
// Bestehenden Eintrag suchen, um zwischen Anlegen und Ändern zu unterscheiden.
//
// Bewusst OHNE notInTrash: Ein Eintrag im Papierkorb belegt seinen
// Bezeichner weiter. Wer ihn neu beschreibt, holt ihn zurück (siehe
// unten) — statt an einer UNIQUE-Verletzung zu scheitern oder
// stillschweigend einen zweiten Eintrag daneben zu legen.
//
// sql.NullString kann, anders als ein normaler string, auch NULL
// aufnehmen: .Valid ist dann false. Genau das unterscheidet hier
// "liegt im Papierkorb" (Zeitpunkt gesetzt) von "normal" (NULL).
var id, createdAt string
var version int
var deletedAt sql.NullString
err := tx.QueryRowContext(ctx,
`SELECT id, version, created_at, deleted_at FROM entries WHERE project_id = ? AND slug = ?`,
w.ProjectID, w.Slug,
).Scan(&id, &version, &createdAt, &deletedAt)
inTrash := deletedAt.Valid
switch {
case errors.Is(err, sql.ErrNoRows):
// Neuer Eintrag.
//
// Ein ExpectedVersion > 0 bedeutet: "ich habe Stand N gelesen und
// will ihn fortschreiben". Existiert der Eintrag nicht (mehr), ist
// das ein Konflikt und kein stillschweigendes Neuanlegen.
if w.ExpectedVersion > 0 {
return ErrConflict
}
id = NewID()
version = 1
createdAt = now
if !w.CreatedAt.IsZero() {
createdAt = formatTime(w.CreatedAt)
}
if !w.UpdatedAt.IsZero() {
now = formatTime(w.UpdatedAt)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO entries (id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, w.ProjectID, w.Slug, w.Title, w.Content, w.Source, w.Author,
boolToInt(w.Curated), version, createdAt, now,
); err != nil {
return fmt.Errorf("Eintrag konnte nicht angelegt werden: %w", err)
}
case err != nil:
return fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
default:
// Vorhandener Eintrag.
//
// Liegt er im Papierkorb und der Schreiber gibt eine gelesene
// Fassung an, hat er einen Stand bearbeitet, den inzwischen jemand
// gelöscht hat — etwa ein offenes Formular im Browser, während ein
// Agent "forget" aufrief. Das ist ein Konflikt, genau wie beim
// zwischenzeitlich geänderten Eintrag: Die Löschung soll nicht
// unbemerkt rückgängig gemacht werden.
if inTrash && w.ExpectedVersion > 0 {
return fmt.Errorf("%w: der Eintrag wurde inzwischen gelöscht und liegt im Papierkorb", ErrConflict)
}
if w.ExpectedVersion > 0 && w.ExpectedVersion != version {
return fmt.Errorf("%w (gelesen: %d, aktuell: %d)", ErrConflict, w.ExpectedVersion, version)
}
// Die bisherige Fassung sichern, bevor sie überschrieben wird —
// die Schlagworte in entry_tags gehören noch zum alten Stand,
// replaceTags läuft erst weiter unten. Bei einem Eintrag aus dem
// Papierkorb ist das der gelöschte Stand: Er geht also auch beim
// Zurückholen durch Überschreiben nicht verloren.
if err := snapshotEntryVersion(ctx, tx, w.ProjectID, id); err != nil {
return err
}
version++
// deleted_at = NULL holt einen Eintrag aus dem Papierkorb zurück.
// Für einen normalen Eintrag ändert es nichts — er war ja schon NULL.
if _, err := tx.ExecContext(ctx,
`UPDATE entries SET title = ?, content = ?, source = ?, author = ?, curated = ?, version = ?, updated_at = ?,
deleted_at = NULL, deleted_by = ''
WHERE id = ?`,
w.Title, w.Content, w.Source, w.Author, boolToInt(w.Curated), version, now, id,
); err != nil {
return fmt.Errorf("Eintrag konnte nicht geändert werden: %w", err)
}
}
// Schlagworte und Verweise werden nicht abgeglichen, sondern ersetzt.
// Das ist einfacher als ein Differenzabgleich und bei dieser Datenmenge
// genauso schnell — der Eintrag ist die Wahrheit, nicht die Historie.
if err := replaceTags(ctx, tx, w.ProjectID, id, tags); err != nil {
return err
}
if err := replaceLinks(ctx, tx, w.ProjectID, id, links); err != nil {
return err
}
result = Entry{
ID: id,
ProjectID: w.ProjectID,
Slug: w.Slug,
Title: w.Title,
Content: w.Content,
Tags: tags,
Source: w.Source,
Author: w.Author,
Curated: w.Curated,
Version: version,
CreatedAt: parseTime(createdAt),
UpdatedAt: parseTime(now),
RestoredFromTrash: inTrash,
}
return nil
})
if err != nil {
return Entry{}, err
}
return result, nil
}
// snapshotEntryVersion sichert den aktuellen Stand eines Eintrags in
// entry_versions, bevor er überschrieben wird — die einzige Stelle, an der
// diese Tabelle beschrieben wird (siehe migrations/004_change_history.sql).
//
// Läuft immer als Teil der Transaktion, die auch die eigentliche Änderung
// vornimmt: Entweder werden alte Fassung und neue Fassung beide geschrieben,
// oder keine von beiden.
func snapshotEntryVersion(ctx context.Context, tx *transaction, projectID, entryID string) error {
var version int
var title, content, source, author string
var curated int
err := tx.QueryRowContext(ctx,
`SELECT version, title, content, source, author, curated FROM entries WHERE id = ?`, entryID,
).Scan(&version, &title, &content, &source, &author, &curated)
if err != nil {
return fmt.Errorf("Fassung konnte nicht gesichert werden: %w", err)
}
rows, err := tx.QueryContext(ctx, `SELECT tag FROM entry_tags WHERE entry_id = ? ORDER BY tag`, entryID)
if err != nil {
return fmt.Errorf("Fassung konnte nicht gesichert werden: %w", err)
}
var tags []string
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
rows.Close()
return err
}
tags = append(tags, tag)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
if _, err := tx.ExecContext(ctx,
`INSERT INTO entry_versions (id, entry_id, project_id, version, title, content, tags, author, source, curated, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
NewID(), entryID, projectID, version, title, content, strings.Join(tags, ","), author, source, curated, NowString(),
); err != nil {
return fmt.Errorf("Fassung konnte nicht gesichert werden: %w", err)
}
return nil
}
// scanEntryVersion liest eine Zeile aus entry_versions.
func scanEntryVersion(scan func(...any) error) (EntryVersion, error) {
var v EntryVersion
var tags string
var curated int
var createdAt string
if err := scan(&v.ID, &v.EntryID, &v.Version, &v.Title, &v.Content, &tags,
&v.Author, &v.Source, &curated, &createdAt); err != nil {
return EntryVersion{}, err
}
if tags != "" {
v.Tags = strings.Split(tags, ",")
}
v.Curated = curated == 1
v.CreatedAt = parseTime(createdAt)
return v, nil
}
const entryVersionColumns = `id, entry_id, version, title, content, tags, author, source, curated, created_at`
// EntryVersions gibt die abgelösten Fassungen eines Eintrags zurück, neueste
// zuerst. Die aktuelle Fassung steht nicht darin — die liefert EntryBySlug.
func (s *Store) EntryVersions(ctx context.Context, entryID string) ([]EntryVersion, error) {
rows, err := s.read.QueryContext(ctx,
`SELECT `+entryVersionColumns+` FROM entry_versions WHERE entry_id = ? ORDER BY version DESC`,
entryID,
)
if err != nil {
return nil, fmt.Errorf("Änderungshistorie konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
var versions []EntryVersion
for rows.Next() {
v, err := scanEntryVersion(rows.Scan)
if err != nil {
return nil, err
}
versions = append(versions, v)
}
return versions, rows.Err()
}
// EntryVersionByNumber liest eine einzelne abgelöste Fassung.
func (s *Store) EntryVersionByNumber(ctx context.Context, entryID string, version int) (EntryVersion, error) {
row := s.read.QueryRowContext(ctx,
`SELECT `+entryVersionColumns+` FROM entry_versions WHERE entry_id = ? AND version = ?`,
entryID, version,
)
v, err := scanEntryVersion(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return EntryVersion{}, ErrNotFound
}
if err != nil {
return EntryVersion{}, fmt.Errorf("Fassung konnte nicht gelesen werden: %w", err)
}
return v, nil
}
// replaceTags ersetzt die Schlagworte eines Eintrags.
//
// project_id wird mitgeschrieben, obwohl es schon am Eintrag steht — siehe die
// Begründung in migrations/002_project_in_child_tables.sql. Diese Funktion und
// replaceLinks sind die einzigen Schreibwege in diese Tabellen, und beide laufen
// in derselben Transaktion wie der Eintrag. Die Redundanz kann deshalb nicht
// auseinanderlaufen.
func replaceTags(ctx context.Context, tx *transaction, projectID, entryID string, tags []string) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM entry_tags WHERE entry_id = ?`, entryID); err != nil {
return fmt.Errorf("Schlagworte konnten nicht ersetzt werden: %w", err)
}
for _, tag := range tags {
if _, err := tx.ExecContext(ctx,
`INSERT INTO entry_tags (entry_id, project_id, tag) VALUES (?, ?, ?)`, entryID, projectID, tag,
); err != nil {
return fmt.Errorf("Schlagwort %q konnte nicht gespeichert werden: %w", tag, err)
}
}
return nil
}
// replaceLinks ersetzt die ausgehenden Verweise eines Eintrags.
func replaceLinks(ctx context.Context, tx *transaction, projectID, entryID string, links []string) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM entry_links WHERE from_entry_id = ?`, entryID); err != nil {
return fmt.Errorf("Verweise konnten nicht ersetzt werden: %w", err)
}
for _, target := range links {
if _, err := tx.ExecContext(ctx,
`INSERT INTO entry_links (from_entry_id, project_id, to_slug) VALUES (?, ?, ?)`,
entryID, projectID, target,
); err != nil {
return fmt.Errorf("Verweis auf %q konnte nicht gespeichert werden: %w", target, err)
}
}
return nil
}
// EntryBySlug liest einen Eintrag samt Schlagworten.
func (s *Store) EntryBySlug(ctx context.Context, projectID, slug string) (Entry, error) {
row := s.read.QueryRowContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ? AND `+notInTrash,
projectID, slug,
)
e, err := scanEntry(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return Entry{}, ErrNotFound
}
if err != nil {
return Entry{}, fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
}
if err := s.attachTags(ctx, []*Entry{&e}); err != nil {
return Entry{}, err
}
return e, nil
}
// EntryFilter schränkt ListEntries ein.
type EntryFilter struct {
Tag string // leer = alle
Limit int // 0 = Standardwert
Offset int
Curated bool // true = nur kuratierte Einträge
// ChangedBefore: nur Einträge, die zuletzt vor diesem Zeitpunkt geändert
// wurden. Leer (Nullwert) = ohne diese Einschränkung. Für den Filter
// „veraltet" der Weboberfläche: StaleCutoff(jetzt).
ChangedBefore time.Time
}
const defaultEntryLimit = 50
// ListEntries gibt Einträge eines Projekts zurück, zuletzt geänderte zuerst.
func (s *Store) ListEntries(ctx context.Context, projectID string, f EntryFilter) ([]Entry, error) {
limit := f.Limit
if limit <= 0 {
limit = defaultEntryLimit
}
// Die Abfrage wird stückweise zusammengesetzt. Wichtig: Werte gehen immer als
// Parameter (?) in die Abfrage, nie per String-Verkettung — sonst wäre das
// eine SQL-Injection-Lücke.
query := `SELECT ` + entryColumns + ` FROM entries WHERE project_id = ? AND ` + notInTrash
values := []any{projectID}
if f.Tag != "" {
// project_id gehört mit in die Unterabfrage, damit der Index
// (project_id, tag) greift statt nur über das Schlagwort zu suchen.
query += ` AND id IN (SELECT entry_id FROM entry_tags WHERE project_id = ? AND tag = ?)`
values = append(values, projectID, strings.ToLower(strings.TrimSpace(f.Tag)))
}
if f.Curated {
query += ` AND curated = 1`
}
if !f.ChangedBefore.IsZero() {
// Textvergleich auf RFC3339-Zeitstempeln, wie überall im Store (siehe
// timeFormat): Das sortiert richtig und braucht keine Datumsfunktion
// der Datenbank.
query += ` AND updated_at < ?`
values = append(values, formatTime(f.ChangedBefore))
}
query += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?`
values = append(values, limit, f.Offset)
rows, err := s.read.QueryContext(ctx, query, values...)
if err != nil {
return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var entries []Entry
for rows.Next() {
e, err := scanEntry(rows.Scan)
if err != nil {
return nil, err
}
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
return entries, s.attachTagsToSlice(ctx, entries)
}
// AllEntries gibt alle Einträge eines Projekts zurück — für den Aufbau des
// Suchindex beim Start und für den Export. Einträge im Papierkorb gehören
// nicht dazu: Sie sollen weder gefunden werden noch im Export wieder
// auftauchen.
func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, error) {
rows, err := s.read.QueryContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND `+notInTrash+` ORDER BY slug`,
projectID,
)
if err != nil {
return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var entries []Entry
for rows.Next() {
e, err := scanEntry(rows.Scan)
if err != nil {
return nil, err
}
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
return entries, s.attachTagsToSlice(ctx, entries)
}
// CountEntries zählt die Einträge eines Projekts (ohne Papierkorb).
func (s *Store) CountEntries(ctx context.Context, projectID string) (int, error) {
var n int
err := s.read.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND `+notInTrash, projectID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err)
}
return n, nil
}
// CountStaleEntries zählt die Einträge eines Projekts, die seit StaleAfter
// nicht geändert wurden (siehe Entry.Stale). Für die Weboberfläche: Liste und
// Nutzungsseite nennen die Zahl, damit ein Team gezielt aufräumen kann.
func (s *Store) CountStaleEntries(ctx context.Context, projectID string, now time.Time) (int, error) {
var n int
err := s.read.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND updated_at < ? AND `+notInTrash,
projectID, formatTime(StaleCutoff(now)),
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("veraltete Einträge konnten nicht gezählt werden: %w", err)
}
return n, nil
}
// ── Schlagworte ──────────────────────────────────────────────────────────────
// ListTags gibt alle Schlagworte eines Projekts mit ihrer Häufigkeit zurück,
// häufigste zuerst.
func (s *Store) ListTags(ctx context.Context, projectID string) ([]TagCount, error) {
// Eine einzige Tabelle statt einer Verknüpfung über entries: Der Aufwand
// hängt jetzt an der Zahl der Schlagworte, nicht an der Zahl der Einträge.
// Siehe migrations/002 — diese Abfrage läuft bei jedem Seitenaufruf, weil
// die Schlagworte in der Seitenleiste stehen.
//
// Die Schlagworte gelöschter Einträge bleiben in entry_tags stehen — sie
// werden beim Wiederherstellen gebraucht. Damit sie hier nicht mitgezählt
// werden, schließt die Unterabfrage die Einträge im Papierkorb aus. Die
// läuft über den kleinen Teilindex idx_entries_trash (migrations/005)
// und kostet deshalb praktisch nichts, egal wie groß das Projekt ist.
rows, err := s.read.QueryContext(ctx,
`SELECT tag, COUNT(*) AS anzahl
FROM entry_tags
WHERE project_id = ?
AND entry_id NOT IN (SELECT id FROM entries WHERE project_id = ? AND deleted_at IS NOT NULL)
GROUP BY tag
ORDER BY anzahl DESC, tag`,
projectID, projectID,
)
if err != nil {
return nil, fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var tags []TagCount
for rows.Next() {
var tc TagCount
if err := rows.Scan(&tc.Tag, &tc.Count); err != nil {
return nil, err
}
tags = append(tags, tc)
}
return tags, rows.Err()
}
// TagCount ist ein Schlagwort mit der Anzahl seiner Einträge.
type TagCount struct {
Tag string
Count int
}
// attachTagsToSlice lädt die Schlagworte für eine Ergebnisliste nach.
func (s *Store) attachTagsToSlice(ctx context.Context, entries []Entry) error {
cursor := make([]*Entry, len(entries))
for i := range entries {
cursor[i] = &entries[i]
}
return s.attachTags(ctx, cursor)
}
// attachTags lädt die Schlagworte mehrerer Einträge in EINER Abfrage nach.
//
// Die naheliegende Lösung wäre, pro Eintrag einmal nachzufragen — das ist das
// klassische "N+1-Problem": bei 50 Einträgen 51 Abfragen. Stattdessen wird eine
// Abfrage mit einer IN-Liste gestellt und das Ergebnis in Go zugeordnet.
func (s *Store) attachTags(ctx context.Context, entries []*Entry) error {
if len(entries) == 0 {
return nil
}
// Platzhalter "?, ?, ?" in der passenden Anzahl bauen.
placeholder := strings.TrimSuffix(strings.Repeat("?,", len(entries)), ",")
values := make([]any, len(entries))
toID := make(map[string]*Entry, len(entries))
for i, e := range entries {
values[i] = e.ID
toID[e.ID] = e
}
rows, err := s.read.QueryContext(ctx,
`SELECT entry_id, tag FROM entry_tags WHERE entry_id IN (`+placeholder+`) ORDER BY tag`,
values...,
)
if err != nil {
return fmt.Errorf("Schlagworte konnten nicht geladen werden: %w", err)
}
defer rows.Close()
for rows.Next() {
var entryID, tag string
if err := rows.Scan(&entryID, &tag); err != nil {
return err
}
if e, ok := toID[entryID]; ok {
e.Tags = append(e.Tags, tag)
}
}
return rows.Err()
}
// BulkUpdateTags fügt einer Auswahl von Einträgen Schlagworte hinzu und/oder
// entfernt welche — eine Sammelbearbeitung, ohne jeden Eintrag einzeln öffnen
// zu müssen.
//
// Läuft in einer Transaktion, damit entweder alle betroffenen Einträge
// aktualisiert werden oder keiner. IDs, die nicht zu projectID gehören, werden
// stillschweigend übersprungen — sonst könnte eine untergeschobene ID über
// dieses eine Formular Einträge eines fremden Projekts verändern.
//
// Gibt die geänderten Einträge zurück, damit der Aufrufer den Suchindex
// nachziehen kann (siehe search.Registry.Update) — ein zweiter Lesezugriff
// nach der Transaktion wäre unnötig.
func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs, add, remove []string) ([]Entry, error) {
add = NormalizeTags(add)
remove = NormalizeTags(remove)
removeSet := make(map[string]bool, len(remove))
for _, t := range remove {
removeSet[t] = true
}
var changed []Entry
now := NowString()
err := s.tx(ctx, func(tx *transaction) error {
for _, id := range entryIDs {
var existingID string
err := tx.QueryRowContext(ctx,
`SELECT id FROM entries WHERE id = ? AND project_id = ? AND `+notInTrash, id, projectID,
).Scan(&existingID)
if errors.Is(err, sql.ErrNoRows) {
continue // gehört nicht zu diesem Projekt, existiert nicht mehr oder liegt im Papierkorb
}
if err != nil {
return fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
}
rows, err := tx.QueryContext(ctx, `SELECT tag FROM entry_tags WHERE entry_id = ? ORDER BY tag`, id)
if err != nil {
return fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err)
}
var previous []string
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
rows.Close()
return err
}
previous = append(previous, tag)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
newer := NormalizeTags(append(previous, add...))
filtered := newer[:0]
for _, t := range newer {
if !removeSet[t] {
filtered = append(filtered, t)
}
}
// Nur schreiben, wenn sich wirklich etwas ändert — sonst würde ein
// Sammelaufruf über eine gemischte Auswahl auch die Einträge
// "berühren", bei denen add/remove gar nichts bewirkt haben.
if stringSlicesEqual(previous, filtered) {
continue
}
// Wie in PutEntry: die alte Fassung sichern, bevor die
// Schlagworte ersetzt werden — entry_tags trägt an dieser
// Stelle noch den vorherigen Stand.
if err := snapshotEntryVersion(ctx, tx, projectID, id); err != nil {
return err
}
if err := replaceTags(ctx, tx, projectID, id, filtered); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE entries SET version = version + 1, updated_at = ? WHERE id = ?`, now, id,
); err != nil {
return fmt.Errorf("Eintrag konnte nicht aktualisiert werden: %w", err)
}
row := tx.QueryRowContext(ctx, `SELECT `+entryColumns+` FROM entries WHERE id = ?`, id)
e, err := scanEntry(row.Scan)
if err != nil {
return fmt.Errorf("Eintrag konnte nicht neu gelesen werden: %w", err)
}
e.Tags = filtered
changed = append(changed, e)
}
return nil
})
if err != nil {
return nil, err
}
return changed, nil
}
// stringSlicesEqual vergleicht zwei (bereits sortierte) Zeichenketten-Listen.
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// ── Verweise ─────────────────────────────────────────────────────────────────
// Backlink ist ein Eintrag, der auf einen anderen verweist.
type Backlink struct {
Slug string
Title string
}
// Backlinks gibt alle Einträge zurück, die auf den angegebenen Slug verweisen.
//
// Das ist die Gegenrichtung der [[…]]-Verweise und ersetzt die frühere
// Ordnerhierarchie: Statt "wo liegt diese Seite im Baum" beantwortet es die
// nützlichere Frage "was hat mit dieser Seite zu tun". Für ein KI-Werkzeug ist
// das zugleich der Weg zu verwandtem Kontext.
func (s *Store) Backlinks(ctx context.Context, projectID, slug string) ([]Backlink, error) {
// Ohne ORDER BY in der Abfrage — und das ist kein Schönheitsfehler:
// Mit "ORDER BY e.title" kippt SQLite die Reihenfolge der Verknüpfung und
// läuft durch alle Einträge des Projekts, statt vom kleinen Verweis-Index
// auszugehen. Gemessen bei 5.000 Einträgen: 3,1 ms statt 9 µs.
//
// Sortiert wird deshalb unten in Go. Rückverweise sind erfahrungsgemäß eine
// Handvoll — dort kostet das Sortieren nichts.
rows, err := s.read.QueryContext(ctx,
`SELECT e.slug, e.title
FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id
WHERE l.project_id = ? AND l.to_slug = ? AND e.deleted_at IS NULL`,
projectID, slug,
)
if err != nil {
return nil, fmt.Errorf("Rückverweise konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var links []Backlink
for rows.Next() {
var b Backlink
if err := rows.Scan(&b.Slug, &b.Title); err != nil {
return nil, err
}
links = append(links, b)
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(links, func(i, j int) bool { return links[i].Title < links[j].Title })
return links, nil
}
// GraphNode ist ein Eintrag in der Graph-Ansicht der Verweise.
type GraphNode struct {
Slug string
Title string
Exists bool // false = Ziel eines Verweises, aber noch kein Eintrag dazu angelegt
}
// GraphEdge ist ein einzelner Verweis zwischen zwei Bezeichnern.
type GraphEdge struct {
From string
To string
}
// LinkGraph gibt alle Einträge eines Projekts als Knoten und alle
// [[…]]-Verweise dazwischen als Kanten zurück — die Grundlage der
// Graph-Ansicht (siehe internal/web/handlers_entries.go).
//
// Verweise auf einen Bezeichner, zu dem es (noch) keinen Eintrag gibt,
// erzeugen trotzdem einen Knoten — mit Exists = false. Das macht in der
// Graph-Ansicht sichtbar, wohin schon verwiesen wird, was aber noch fehlt,
// statt solche Verweise stillschweigend zu unterschlagen.
func (s *Store) LinkGraph(ctx context.Context, projectID string) ([]GraphNode, []GraphEdge, error) {
entryRows, err := s.read.QueryContext(ctx,
`SELECT slug, title FROM entries WHERE project_id = ? AND `+notInTrash, projectID,
)
if err != nil {
return nil, nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
}
nodes := make(map[string]*GraphNode)
for entryRows.Next() {
var slug, title string
if err := entryRows.Scan(&slug, &title); err != nil {
entryRows.Close()
return nil, nil, err
}
nodes[slug] = &GraphNode{Slug: slug, Title: title, Exists: true}
}
if err := entryRows.Err(); err != nil {
entryRows.Close()
return nil, nil, err
}
entryRows.Close()
linkRows, err := s.read.QueryContext(ctx,
`SELECT e.slug, l.to_slug
FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id
WHERE l.project_id = ? AND e.deleted_at IS NULL`,
projectID,
)
if err != nil {
return nil, nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err)
}
defer linkRows.Close()
var edges []GraphEdge
for linkRows.Next() {
var from, to string
if err := linkRows.Scan(&from, &to); err != nil {
return nil, nil, err
}
if _, ok := nodes[to]; !ok {
nodes[to] = &GraphNode{Slug: to, Title: to, Exists: false}
}
edges = append(edges, GraphEdge{From: from, To: to})
}
if err := linkRows.Err(); err != nil {
return nil, nil, err
}
nodeList := make([]GraphNode, 0, len(nodes))
for _, k := range nodes {
nodeList = append(nodeList, *k)
}
sort.Slice(nodeList, func(i, j int) bool { return nodeList[i].Slug < nodeList[j].Slug })
return nodeList, edges, nil
}
// boolToInt wandelt einen Wahrheitswert in die Datenbankdarstellung.
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}