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 }