FindSlug returned the first hit, so a wing's own page of a name masked another page of the same name (the retired dirs are searched before the wings, and wings alphabetically). FindSlugs returns every match and callers refuse any that is not their own. Found by the legacy-move planner's own test. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
195 lines
5.2 KiB
Go
195 lines
5.2 KiB
Go
// ingestion/internal/wiki/inventory.go
|
|
package wiki
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
)
|
|
|
|
// LoadInventory walks brain/wiki/ and returns all pages grouped by type.
|
|
// Missing subdirectories are silently skipped. Pages are listed and read
|
|
// through the brain root, regular files only (hyperguild#157); a type
|
|
// directory that is a symlink is an error.
|
|
func LoadInventory(brainDir string) (map[PageType][]Entry, error) {
|
|
result := map[PageType][]Entry{
|
|
PageTypeConcept: {},
|
|
PageTypeEntity: {},
|
|
PageTypeSource: {},
|
|
}
|
|
bfs, err := brainfs.Open(brainDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open brain: %w", err)
|
|
}
|
|
defer func() { _ = bfs.Close() }()
|
|
for pt := range result {
|
|
dir := "wiki/" + string(pt)
|
|
names, err := bfs.ReadDirRegular(dir)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read dir %s: %w", dir, err)
|
|
}
|
|
for _, name := range names {
|
|
if !strings.HasSuffix(name, ".md") {
|
|
continue
|
|
}
|
|
slug := strings.TrimSuffix(name, ".md")
|
|
title, aliases := readFrontmatter(bfs, dir+"/"+name, slug)
|
|
result[pt] = append(result[pt], Entry{Slug: slug, Title: title, Aliases: aliases, Type: pt})
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// readFrontmatter extracts title and aliases from YAML frontmatter.
|
|
// Falls back to slug for title and empty aliases on any error.
|
|
func readFrontmatter(bfs *brainfs.Brain, rel, fallbackSlug string) (title string, aliases []string) {
|
|
title = fallbackSlug
|
|
content, err := bfs.ReadRegular(rel)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
scanner := bufio.NewScanner(bytes.NewReader(content))
|
|
inFM := false
|
|
inAliases := false
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.TrimSpace(line) == "---" {
|
|
if !inFM {
|
|
inFM = true
|
|
continue
|
|
}
|
|
break // end of frontmatter
|
|
}
|
|
if !inFM {
|
|
continue
|
|
}
|
|
|
|
// Detect alias list items (lines starting with " - ").
|
|
if inAliases {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "- ") {
|
|
aliases = append(aliases, strings.TrimPrefix(trimmed, "- "))
|
|
continue
|
|
}
|
|
inAliases = false // end of alias block
|
|
}
|
|
|
|
key, val, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(key) {
|
|
case "title":
|
|
title = strings.Trim(strings.TrimSpace(val), `"'`)
|
|
case "aliases":
|
|
inAliases = true
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// LoadWingInventory returns what an ingest into one wing can match against:
|
|
// that wing's sources (wiki/<wing>/sources/) and the SHARED concepts and
|
|
// entities (wiki/concepts/, wiki/entities/), which belong to no single wing
|
|
// because their slug is the graph's global identity. Other wings' sources are
|
|
// not read.
|
|
func LoadWingInventory(brainDir, wing string) (map[PageType][]Entry, error) {
|
|
result, err := LoadInventory(brainDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result[PageTypeSource] = []Entry{} // the retired source layout is not this wing's
|
|
bfs, err := brainfs.Open(brainDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open brain: %w", err)
|
|
}
|
|
defer func() { _ = bfs.Close() }()
|
|
dir := "wiki/" + wing + "/sources"
|
|
names, err := bfs.ReadDirRegular(dir)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return result, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read dir %s: %w", dir, err)
|
|
}
|
|
for _, name := range names {
|
|
if !strings.HasSuffix(name, ".md") {
|
|
continue
|
|
}
|
|
rel := dir + "/" + name
|
|
slug := strings.TrimSuffix(name, ".md")
|
|
title, aliases := readFrontmatter(bfs, rel, slug)
|
|
result[PageTypeSource] = append(result[PageTypeSource], Entry{Slug: slug, Title: title, Aliases: aliases, Type: PageTypeSource, Path: rel})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// FindSlugs returns the brain-relative path of EVERY existing page whose
|
|
// basename is slug, searching the retired dirs (wiki/{sources,concepts,
|
|
// entities}/) and every wing hall (wiki/<wing>/<hall>/), sorted. The slug is a
|
|
// page's graph identity, so two pages must not share one; returning all of
|
|
// them (not the first) is what lets a caller tell "only my own page" from "my
|
|
// own page and somebody else's".
|
|
func FindSlugs(brainDir, slug string) ([]string, error) {
|
|
bfs, err := brainfs.Open(brainDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open brain: %w", err)
|
|
}
|
|
defer func() { _ = bfs.Close() }()
|
|
file := slug + ".md"
|
|
var found []string
|
|
check := func(dir string) error {
|
|
names, err := bfs.ReadDirRegular(dir)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, n := range names {
|
|
if n == file {
|
|
found = append(found, dir+"/"+file)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
for _, d := range []string{"wiki/sources", "wiki/concepts", "wiki/entities"} {
|
|
if err := check(d); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
wings, err := bfs.ReadDirDirs("wiki")
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return found, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, w := range wings {
|
|
if w == "sources" || w == "concepts" || w == "entities" {
|
|
continue
|
|
}
|
|
halls, err := bfs.ReadDirDirs("wiki/" + w)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, h := range halls {
|
|
if err := check("wiki/" + w + "/" + h); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(found)
|
|
return found, nil
|
|
}
|