package doc
import (
"fmt"
"regexp"
"strings"
)
// LogSection is the Section every log entry carries in the index. It is not a
// real directory: the entries all come out of one document. Default search
// excludes it, so a query returns the document describing a thing rather than
// the log entry paraphrasing it, and an explicit filter reaches it.
const LogSection = "log"
// entryHeadRe matches an activity log's entry header:
// "## [YYYY-MM-DD] action | Title".
var entryHeadRe = regexp.MustCompile(`^##\s+\[(\d{4}-\d{2}-\d{2})\]\s+([a-z]+)\s*\|\s*(.*)$`)
// LogEntry is one dated entry of an activity log (a document marked
// `type: log`), indexed as its own document.
//
// Indexing such a log as a single document is the wrong shape twice over: it is
// a large body of text summarising other documents, so it produces chunks that
// are near duplicates of what they describe and compete with them for the same
// queries, and a hit anywhere in the file resolves to the whole file. Split by
// entry, each piece is the size of the thing it describes and answers the
// question a log is uniquely good for — when something landed, what changed in
// a month, what came out of one session.
type LogEntry struct {
ID string // "log#<date>-<n>", unique even when a date repeats
Date string // YYYY-MM-DD
Action string // ingest | query | lint | update
Title string
Body string // entry text, header line excluded
Anchor string // heading anchor within /log
}
// SplitLog parses an activity log's body into entries. Text before the first
// entry header (the file's own title) is dropped. A file that matches no header
// at all yields no entries, and the caller keeps treating it as one ordinary
// document.
func SplitLog(body []byte) []LogEntry {
lines := strings.Split(string(body), "\n")
var (
entries []LogEntry
cur *LogEntry
buf []string
perDate = map[string]int{}
)
flush := func() {
if cur == nil {
return
}
cur.Body = strings.TrimSpace(strings.Join(buf, "\n"))
entries = append(entries, *cur)
cur, buf = nil, nil
}
inFence := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
inFence = !inFence
}
if !inFence {
if m := entryHeadRe.FindStringSubmatch(line); m != nil {
flush()
date, action, title := m[1], m[2], strings.TrimSpace(m[3])
perDate[date]++
cur = &LogEntry{
ID: fmt.Sprintf("log#%s-%d", date, perDate[date]),
Date: date,
Action: action,
Title: title,
Anchor: fmt.Sprintf("e-%s-%d", date, perDate[date]),
}
continue
}
}
if cur != nil {
buf = append(buf, line)
}
}
flush()
return entries
}
// SearchText is what the keyword index stores for an entry: the header fields
// followed by the body, with wikilink brackets left in place — the renderer is
// not involved here, and the bracketed names are still the words a reader would
// search for.
func (e LogEntry) SearchText() string {
return e.Date + " " + e.Action + " " + e.Title + "\n" + e.Body
}