package api import ( "fmt" "os" "path/filepath" "regexp" "sort" "strings" "time" "github.com/mathiasbq/hyperguild/ingestion/internal/brain" ) // PendingNote describes a raw/ note awaiting human promotion to the wiki. type PendingNote struct { Filename string `json:"filename"` CreatedAt string `json:"created_at"` SizeBytes int64 `json:"size_bytes"` Excerpt string `json:"excerpt"` } // datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when // deriving the default promoted slug. var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-`) // ListPending returns the notes in brain/raw/ awaiting review, oldest-first // (natural review order). An absent raw/ dir yields an empty slice, not an // error. Only .md files are listed; tunnel-candidate files are skipped. func ListPending(brainDir string) ([]PendingNote, error) { dir := filepath.Join(brainDir, "raw") entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return []PendingNote{}, nil } return nil, fmt.Errorf("read raw dir: %w", err) } out := make([]PendingNote, 0, len(entries)) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") { continue } info, statErr := e.Info() if statErr != nil { continue } raw, readErr := os.ReadFile(filepath.Join(dir, e.Name())) if readErr != nil { continue } fm, body := parseFrontmatter(string(raw)) created := fm.get("created_at") if created == "" { created = info.ModTime().UTC().Format(time.RFC3339) } out = append(out, PendingNote{ Filename: e.Name(), CreatedAt: created, SizeBytes: info.Size(), Excerpt: excerpt(body, 200), }) } sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt }) return out, nil } // PromoteOptions identifies a raw note to promote and its wiki destination. type PromoteOptions struct { Filename string // basename in brain/raw/ Wing string Hall string Slug string // optional; defaults to Filename minus date prefix + .md // Origin/WrittenBy re-attribute the promoted note to the promoter: the raw // note's own frontmatter claims are not provenance (hyperguild#145). Origin string WrittenBy string } // PromoteNote moves a note from brain/raw/ into the structured wiki: it // rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at and // any custom fields), writes to brain/wiki///.md, deletes // the source, then rebuilds the wing index and runs auto-tunnel detection. // // It is atomic from the caller's view: validation (hall, wing, slug, // collision) happens before any filesystem change, and the source is deleted // only after the destination write succeeds (write-then-delete, never move). // Returns the promoted note's path relative to brainDir. func PromoteNote(brainDir string, opts PromoteOptions) (string, error) { // Validate filename (basename only — no traversal) before touching fs. base := filepath.Base(opts.Filename) if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) { return "", fmt.Errorf("invalid filename %q", opts.Filename) } slug := opts.Slug if slug == "" { slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "") } // NotePath validates hall + wing + slug; do this before reading anything. dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug) if err != nil { return "", err } src := filepath.Join(brainDir, "raw", base) raw, err := os.ReadFile(src) if err != nil { if os.IsNotExist(err) { return "", fmt.Errorf("pending note %q does not exist in raw/", base) } return "", fmt.Errorf("read source: %w", err) } // Collision: never silently overwrite an existing promoted note. if _, statErr := os.Stat(dest); statErr == nil { rel, _ := filepath.Rel(brainDir, dest) return "", fmt.Errorf("target %s already exists; choose a different slug", filepath.ToSlash(rel)) } fm, body := parseFrontmatter(string(raw)) now := time.Now().UTC().Format(time.RFC3339) fm.set("wing", brain.Sanitise(opts.Wing)) fm.set("hall", opts.Hall) if fm.get("created_at") == "" { fm.set("created_at", now) } fm.set("promoted_at", now) if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return "", fmt.Errorf("create wing dir: %w", err) } // Write-then-delete: the source survives any write failure. promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy) if err != nil { return "", err } if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil { return "", fmt.Errorf("write promoted note: %w", err) } if err := os.Remove(src); err != nil { return "", fmt.Errorf("promoted note written but source removal failed: %w", err) } rel, _ := filepath.Rel(brainDir, dest) relSlash := filepath.ToSlash(rel) // Best-effort wiki upkeep — the note is already promoted. _ = brain.BuildWingIndex(brainDir, opts.Wing) _ = brain.AutoTunnel(brainDir, relSlash, body) return relSlash, nil } // RejectOptions identifies a raw note to reject and why. type RejectOptions struct { Filename string // basename in brain/raw/ Reason string // optional } // RejectNote records a human decision not to promote a brain/raw/ note, // without silently losing it: it stamps rejected_at (+ reject_reason when // given) into frontmatter and moves the file to brain/raw/rejected/, using // the same write-then-delete safety PromoteNote uses — the source survives // any write failure — and the same basename-only filename validation. // // A rejected note never entered the wiki, so no index rebuild or // auto-tunnel run: unlike PromoteNote, this is the end of the note's // journey, not the start of a new one. It stays out of ListPending's view // (raw/rejected/ is a subdirectory, and ListPending's os.ReadDir on raw/ // does not recurse), so a rejected note is never re-surfaced as pending — // the human decision, once made, stays made, the same principle behind // this estate's "issue status is surfaced, not auto-closed, but once a // human decides, the decision is recorded" rule applied one level earlier. func RejectNote(brainDir string, opts RejectOptions) (string, error) { base := filepath.Base(opts.Filename) if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) { return "", fmt.Errorf("invalid filename %q", opts.Filename) } src := filepath.Join(brainDir, "raw", base) raw, err := os.ReadFile(src) if err != nil { if os.IsNotExist(err) { return "", fmt.Errorf("pending note %q does not exist in raw/", base) } return "", fmt.Errorf("read source: %w", err) } dest := filepath.Join(brainDir, "raw", "rejected", base) if _, statErr := os.Stat(dest); statErr == nil { rel, _ := filepath.Rel(brainDir, dest) return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel)) } fm, body := parseFrontmatter(string(raw)) fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339)) if opts.Reason != "" { fm.set("reject_reason", opts.Reason) } if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return "", fmt.Errorf("create rejected dir: %w", err) } // Write-then-delete: the source survives any write failure. if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil { return "", fmt.Errorf("write rejected note: %w", err) } if err := os.Remove(src); err != nil { return "", fmt.Errorf("rejected note written but source removal failed: %w", err) } rel, _ := filepath.Rel(brainDir, dest) return filepath.ToSlash(rel), nil } // excerpt returns the first n runes of s, trimmed, single-spaced. func excerpt(s string, n int) string { s = strings.TrimSpace(s) r := []rune(s) if len(r) > n { r = r[:n] } return strings.TrimSpace(string(r)) }