894 lines
30 KiB
Go
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
|
|
}
|