package store import ( "context" "database/sql" "errors" "fmt" "regexp" "strings" ) // slugPattern beschreibt, wie ein Bezeichner aussehen darf: Kleinbuchstaben, // Ziffern, Bindestrich, Unterstrich. // // Slugs tauchen in URLs und in MCP-Aufrufen auf. Die enge Zeichenmenge nimmt von // vornherein alle Fragen nach Kodierung, Groß-/Kleinschreibung und Sonderzeichen // aus dem Weg. var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) // ValidSlug prüft einen Bezeichner. func ValidSlug(slug string) bool { return slugPattern.MatchString(slug) } // Slugify macht aus beliebigem Text einen brauchbaren Bezeichner. // Wird als Vorschlag verwendet, wenn ein Mensch nur einen Titel eingibt. func Slugify(text string) string { var b strings.Builder letzterStrich := false for _, r := range strings.ToLower(strings.TrimSpace(text)) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) letzterStrich = false case r == 'ä': b.WriteString("ae") letzterStrich = false case r == 'ö': b.WriteString("oe") letzterStrich = false case r == 'ü': b.WriteString("ue") letzterStrich = false case r == 'ß': b.WriteString("ss") letzterStrich = false default: // Alles andere wird zu einem Bindestrich, aber nie zwei am Stück. if !letzterStrich && b.Len() > 0 { b.WriteRune('-') letzterStrich = true } } } slug := strings.Trim(b.String(), "-") if len(slug) > 64 { slug = strings.Trim(slug[:64], "-") } return slug } // ── Projekte ───────────────────────────────────────────────────────────────── // // Die Workspace-Verwaltung selbst (EnsureDefaultWorkspace, CreateWorkspace, …) // lebt in control.go — sie arbeitet auf der zentralen Kontroll-Datenbank, nicht // auf dieser Workspace-Datei. // CreateProject legt ein neues Projekt an. func (s *Store) CreateProject(ctx context.Context, workspaceID, slug, name, description string) (Project, error) { if !ValidSlug(slug) { return Project{}, fmt.Errorf("ungültiger Projekt-Bezeichner %q — erlaubt sind a-z, 0-9, '-' und '_'", slug) } if strings.TrimSpace(name) == "" { name = slug } p := Project{ ID: NewID(), WorkspaceID: workspaceID, Slug: slug, Name: name, Description: description, } jetzt := NowString() _, err := s.schreiben.ExecContext(ctx, `INSERT INTO projects (id, workspace_id, slug, name, description, created_at) VALUES (?, ?, ?, ?, ?, ?)`, p.ID, p.WorkspaceID, p.Slug, p.Name, p.Description, jetzt, ) if isUniqueViolation(err) { return Project{}, fmt.Errorf("Projekt %q: %w", slug, ErrDuplicate) } if err != nil { return Project{}, fmt.Errorf("Projekt konnte nicht angelegt werden: %w", err) } p.CreatedAt = parseTime(jetzt) return p, nil } // projectColumns steht in jeder Projekt-Abfrage — einmal definiert, damit // Spaltenreihenfolge und scanProject nicht auseinanderlaufen können. const projectColumns = `id, workspace_id, slug, name, description, created_at, write_limit_hourly` // scanProject liest eine Projektzeile. // Nimmt ein Interface statt *sql.Row oder *sql.Rows, damit dieselbe Funktion für // Einzelabfragen und für Schleifen über Ergebnismengen taugt. func scanProject(scan func(...any) error) (Project, error) { var p Project var createdAt string if err := scan(&p.ID, &p.WorkspaceID, &p.Slug, &p.Name, &p.Description, &createdAt, &p.WriteLimitHourly); err != nil { return Project{}, err } p.CreatedAt = parseTime(createdAt) return p, nil } // ProjectByID liest ein Projekt anhand seiner ID. func (s *Store) ProjectByID(ctx context.Context, id string) (Project, error) { row := s.lesen.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id) p, err := scanProject(row.Scan) if errors.Is(err, sql.ErrNoRows) { return Project{}, ErrNotFound } if err != nil { return Project{}, fmt.Errorf("Projekt konnte nicht gelesen werden: %w", err) } return p, nil } // ProjectBySlug liest ein Projekt anhand seines Bezeichners innerhalb eines Workspace. func (s *Store) ProjectBySlug(ctx context.Context, workspaceID, slug string) (Project, error) { row := s.lesen.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE workspace_id = ? AND slug = ?`, workspaceID, slug, ) p, err := scanProject(row.Scan) if errors.Is(err, sql.ErrNoRows) { return Project{}, ErrNotFound } if err != nil { return Project{}, fmt.Errorf("Projekt konnte nicht gelesen werden: %w", err) } return p, nil } // ListProjects gibt alle Projekte eines Workspace zurück, alphabetisch sortiert. func (s *Store) ListProjects(ctx context.Context, workspaceID string) ([]Project, error) { rows, err := s.lesen.QueryContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE workspace_id = ? ORDER BY name`, workspaceID, ) if err != nil { return nil, fmt.Errorf("Projekte konnten nicht gelesen werden: %w", err) } defer rows.Close() var projekte []Project for rows.Next() { p, err := scanProject(rows.Scan) if err != nil { return nil, err } projekte = append(projekte, p) } return projekte, rows.Err() } // ProjectUpdate sind die änderbaren Felder eines Projekts. // // Ein Struct statt einer wachsenden Parameterliste: Beim Aufruf steht dann an // jedem Wert, was er bedeutet — bei UpdateProject(ctx, id, "Team", "", 0) wüsste // das niemand mehr. Dasselbe Muster wie bei EntryWrite. // // Was hier NICHT drinsteht, ist der Bezeichner. Der bleibt fest: Er steckt in // URLs und in der MCP-Konfiguration jedes Clients — ihn zu ändern hieße, alle // Agenten stillschweigend abzuhängen. type ProjectUpdate struct { Name string Description string // WriteLimitHourly ist die eigene Obergrenze für schreibende Aufrufe. // 0 nimmt eine gesetzte Sonderregel zurück — dann gilt wieder der Standard // aus der Umgebung. WriteLimitHourly int } // UpdateProject ändert Name, Beschreibung und Schreibbudget eines Projekts. // // Alle drei Felder in einer Anweisung: Ein Speichern in der Verwaltung ist ein // Vorgang, also soll er auch einer sein — sonst kann die Hälfte davon gelingen // und die andere scheitern. func (s *Store) UpdateProject(ctx context.Context, id string, u ProjectUpdate) error { if strings.TrimSpace(u.Name) == "" { return fmt.Errorf("der Name darf nicht leer sein") } // Negative Werte gibt es nicht: Sie würden im Schreibbudget als // "abgeschaltet" gelesen und wären damit das Gegenteil einer Grenze. if u.WriteLimitHourly < 0 { return fmt.Errorf("Obergrenze darf nicht negativ sein (%d)", u.WriteLimitHourly) } res, err := s.schreiben.ExecContext(ctx, `UPDATE projects SET name = ?, description = ?, write_limit_hourly = ? WHERE id = ?`, u.Name, u.Description, u.WriteLimitHourly, id, ) if err != nil { return fmt.Errorf("Projekt konnte nicht geändert werden: %w", err) } return requireOneRow(res) } // DeleteProject löscht ein Projekt mit allen Keys, Einträgen und Verweisen. // // Das Aufräumen übernehmen die ON DELETE CASCADE-Regeln im Schema — deshalb ist // wichtig, dass foreign_keys eingeschaltet ist (siehe Open). func (s *Store) DeleteProject(ctx context.Context, id string) error { res, err := s.schreiben.ExecContext(ctx, `DELETE FROM projects WHERE id = ?`, id) if err != nil { return fmt.Errorf("Projekt konnte nicht gelöscht werden: %w", err) } return requireOneRow(res) } // requireOneRow macht aus "keine Zeile betroffen" ein ErrNotFound. func requireOneRow(res sql.Result) error { n, err := res.RowsAffected() if err != nil { return err } if n == 0 { return ErrNotFound } return nil }