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#-", 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 }