package search import ( "fmt" "path" "strings" "sync" "sourcecraft.dev/bigbes/sr-ht-spec/doc" "sourcecraft.dev/bigbes/sr-ht-spec/gitx" ) // renderer is shared: doc.Renderer is documented as reusable and // concurrency-safe, and building one per extraction would rebuild the whole // goldmark pipeline for every space. var renderer = sync.OnceValue(doc.NewRenderer) // Bodies keys a revision's documents by tree path, which is the shape Extract // wants them in. It pairs with doc.FromDocuments: the same []gitx.Document // builds the Archive and supplies the text. func Bodies(docs []gitx.Document) map[string][]byte { m := make(map[string][]byte, len(docs)) for _, d := range docs { m[d.Path] = d.Data } return m } // Extract projects one space at one revision into the documents the index // stores. bodies holds each page's raw markdown — frontmatter included, exactly // gitx.Document.Data — keyed by doc.Page.Path. // // A page in the archive with no body in bodies is an error, not a page indexed // with an empty body. The two are indistinguishable once indexed, and the // second is how a document silently stops being findable. // // Three document shapes come out, matching what doc/ models: // // - an ordinary document, indexed as its frontmatter projected to "key: // value" lines followed by its rendered plain text. The frontmatter is in // there because tags, owners and summaries render as chips rather than // prose, and a search for one of them should still find the document. // - a catalog (`type: catalog`, or index.md), indexed by title and section // only. A catalog is a page of one-line descriptions of other documents; // indexed whole, a query lands on the description instead of on the // document that owns it. // - an activity log (`type: log`, or log.md), which contributes its own // title-only document plus one document per dated entry. A hit anywhere in // a log otherwise resolves to the whole log; split, each entry is the size // of the thing it describes and carries an anchor into it. func Extract(arc *doc.Archive, bodies map[string][]byte) ([]Document, error) { if arc == nil { return nil, fmt.Errorf("search: Extract needs an archive") } pages := arc.All() out := make([]Document, 0, len(pages)) r := renderer() for _, p := range pages { src, ok := bodies[p.Path] if !ok { return nil, fmt.Errorf("search: no body supplied for %s in %s", p.Path, arc.Space) } front, body := doc.ParseFront(src) dir := path.Dir(p.Path) if dir == "." { dir = "" } res := r.Render(body, dir, arc) d := Document{ Space: arc.Space, ID: p.ID, Rev: arc.Rev, Path: p.Path, Section: p.Section, Title: p.Title, Text: front.SearchText() + res.PlainText, } switch p.Kind { case doc.KindCatalog: d.Text = "" case doc.KindLog: d.Section = doc.LogSection d.Text = "" out = append(out, d) out = append(out, logEntries(arc, p, body)...) continue } out = append(out, d) } return out, nil } // logEntries splits an activity log into one indexable document per dated // entry. // // The entry ids doc.SplitLog produces are "log#-", named after // warren's single vault-wide log. In a space that is not unique: a second // document marked `type: log` — or simply a second file named log.md in another // directory — produces the same ids, and in one index the same ids are the same // documents, so one log would silently overwrite the other. The owning page's // id is therefore substituted for the "log" prefix, which is a no-op for a log // whose page id is in fact "log" and disambiguates every other case. The result // also resolves better: "notes/dev-log#2026-05-31-1" names the document the // entry is in. func logEntries(arc *doc.Archive, p *doc.Page, body []byte) []Document { entries := doc.SplitLog(body) out := make([]Document, 0, len(entries)) for _, e := range entries { suffix := strings.TrimPrefix(e.ID, "log#") out = append(out, Document{ Space: arc.Space, ID: p.ID + "#" + suffix, Rev: arc.Rev, Path: p.Path, Anchor: e.Anchor, Section: doc.LogSection, Title: e.Date + " " + e.Title, Text: e.SearchText(), }) } return out }