package render import ( "context" "encoding/json" "sort" "strconv" "strings" "time" "git.pablomurad.com/pablo/patchwork/internal/config" "git.pablomurad.com/pablo/patchwork/internal/diff" "git.pablomurad.com/pablo/patchwork/internal/doc" "git.pablomurad.com/pablo/patchwork/internal/store" ) // Model is everything public, read from the store in one pass. Retracted // revisions, removed pages and pages without a slug are left out. type Model struct { Cfg *config.Config Pages []PageDetail // by path Revisions []Revision // newest first Versions map[string][]*Version // by slug, oldest first Updated time.Time // latest change of anything public } // Load reads the public state of the store. func Load(ctx context.Context, st *store.Store, cfg *config.Config) (*Model, error) { m := &Model{Cfg: cfg, Versions: map[string][]*Version{}} opt := cfg.DocOptions() pages, err := st.Pages(ctx, "", false) if err != nil { return nil, err } for _, p := range pages { if p.Slug == "" { continue } revs, err := st.Revisions(ctx, p.ID) if err != nil { return nil, err } last, err := st.LastSnapshot(ctx, p.ID) if err != nil { return nil, err } summary := PageSummary{ UID: p.UID, Slug: p.Slug, Path: p.Path, Title: titleOr(last.Title, p.Slug), URL: m.url(cfg.Site.PageURL, p.Slug, 0), HistoryURL: m.url(cfg.Site.HistoryURL, p.Slug, 0), Maturity: last.Maturity, OriginalDate: p.OriginalDate, FirstSeenAt: stamp(p.FirstSeenAt), } ref := PageRef{UID: p.UID, Slug: p.Slug, Title: summary.Title, URL: summary.URL} var public []store.Revision for _, r := range revs { if r.RetractedAt == nil { public = append(public, r) } } var versions []*Version var history []VersionSummary var prev *store.Revision var prevDoc *doc.Doc for i := range public { r := &public[i] snap, err := st.Snapshot(ctx, r.SnapshotID) if err != nil { return nil, err } d := doc.Parse([]byte(snap.Raw), opt) v := &Version{ Page: ref, Number: r.Number, Baseline: r.Baseline(), PublishedAt: stamp(r.PublishedAt), UpdatedAt: stamp(r.UpdatedAt), Note: publicNote(r), Kind: r.Kind, Maturity: r.Maturity, URL: m.url(cfg.Site.VersionURL, p.Slug, r.Number), Title: titleOr(d.Title, p.Slug), BodyMarkdown: strings.TrimLeft(d.Body, "\n"), Frontmatter: visibleFrontmatter(d, opt.DiffKeys), } if prev != nil { full, err := cachedDiff(r, prevDoc, d, opt.DiffKeys) if err != nil { return nil, err } if full.Ops == nil { full.Ops = []diff.Op{} // the contract has a list, never null } v.BaseNumber, v.Diff = prev.Number, &full m.Revisions = append(m.Revisions, m.revision(ref, summary, r, prev.Number, full)) summary.RevisionCount++ summary.LastRevisedAt = stamp(r.PublishedAt) } summary.LatestNumber = r.Number if r.UpdatedAt.After(m.Updated) { m.Updated = r.UpdatedAt } versions = append(versions, v) history = append(history, VersionSummary{ Number: r.Number, Baseline: r.Baseline(), PublishedAt: v.PublishedAt, UpdatedAt: v.UpdatedAt, Note: v.Note, Kind: r.Kind, Maturity: r.Maturity, URL: v.URL, WordsAdded: r.WordsAdded, WordsRemoved: r.WordsRemoved, }) prev, prevDoc = r, d } for _, v := range versions { v.LatestNumber = summary.LatestNumber } for i, j := 0, len(history)-1; i < j; i, j = i+1, j-1 { history[i], history[j] = history[j], history[i] } m.Versions[p.Slug] = versions m.Pages = append(m.Pages, PageDetail{PageSummary: summary, Versions: history}) } sort.SliceStable(m.Revisions, func(i, j int) bool { a, b := m.Revisions[i], m.Revisions[j] if !a.published.Equal(b.published) { return a.published.After(b.published) } return a.ID > b.ID }) return m, nil } func (m *Model) revision(ref PageRef, s PageSummary, r *store.Revision, base int, full diff.Full) Revision { ex, cut := diff.Excerpt(full.Ops, 1, m.Cfg.Feeds.ExcerptBlocks) if ex == nil { ex = []diff.Op{} } meta := full.Meta if meta == nil { meta = []diff.MetaChange{} } return Revision{ ID: m.entryID(s.UID, r.Number), Number: r.Number, BaseNumber: base, Page: ref, Kind: r.Kind, Maturity: r.Maturity, Note: r.Note, PublishedAt: stamp(r.PublishedAt), UpdatedAt: stamp(r.UpdatedAt), OriginalDate: s.OriginalDate, VersionURL: m.url(m.Cfg.Site.VersionURL, s.Slug, r.Number), HistoryURL: s.HistoryURL, Stats: full.Stats, MetaChanges: meta, Excerpt: Excerpt{Truncated: cut, Blocks: ex}, published: r.PublishedAt, updated: r.UpdatedAt, } } // cachedDiff uses the diff stored with the revision when the current // algorithm made it, and recomputes otherwise. The engine refreshes a page's // cached diffs whenever a retraction changes which version is the base, so // a cache of the current version is always against the right base. func cachedDiff(r *store.Revision, base, cur *doc.Doc, keys []string) (diff.Full, error) { if r.DiffJSON != "" && r.DiffVersion == diff.Version { var full diff.Full err := json.Unmarshal([]byte(r.DiffJSON), &full) return full, err } return diff.Compare(base, cur, keys), nil } // publicNote hides the baseline's note: it was written before Patchwork // saw the page and was never announced. func publicNote(r *store.Revision) string { if r.Baseline() { return "" } return r.Note } func visibleFrontmatter(d *doc.Doc, keys []string) map[string]any { out := map[string]any{} for _, k := range keys { if v, ok := d.Frontmatter[k]; ok { out[k] = v } } return out } // url expands a site URL pattern and makes it absolute when base_url is set. func (m *Model) url(pattern, slug string, n int) string { u := strings.ReplaceAll(pattern, "{slug}", slug) u = strings.ReplaceAll(u, "{n}", strconv.Itoa(n)) if base := strings.TrimRight(m.Cfg.Site.BaseURL, "/"); base != "" && strings.HasPrefix(u, "/") { return base + u } return u } // entryID is a feed entry id that never changes: a tag: URI when the site // declares an authority, otherwise a URN built from the page's random uid. func (m *Model) entryID(uid string, n int) string { if a := m.Cfg.Site.TagAuthority; a != "" { return "tag:" + a + ":patchwork/" + uid + "/r" + strconv.Itoa(n) } return "urn:patchwork:" + uid + ":r" + strconv.Itoa(n) } func (m *Model) feedID(suffix string) string { if a := m.Cfg.Site.TagAuthority; a != "" { return "tag:" + a + ":patchwork/" + suffix } return "urn:patchwork:" + strings.ReplaceAll(suffix, "/", ":") } // Page returns a page's detail by slug. func (m *Model) Page(slug string) (*PageDetail, bool) { for i := range m.Pages { if m.Pages[i].Slug == slug { return &m.Pages[i], true } } return nil, false } // PageRevisions returns one page's published revisions, newest first. func (m *Model) PageRevisions(slug string) []Revision { var out []Revision for _, r := range m.Revisions { if r.Page.Slug == slug { out = append(out, r) } } return out } func stamp(t time.Time) string { return t.UTC().Format(time.RFC3339) } func titleOr(title, slug string) string { if title != "" { return title } return slug }