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 } // 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. if err := os.WriteFile(dest, []byte(fm.render()+body), 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 } // 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)) }