9c9576b5b9
ci / test (macos-latest) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / lint (push) Has been cancelled
ci / govulncheck (push) Has been cancelled
ci / tidy (push) Has been cancelled
ci / webview (push) Has been cancelled
ci / windows-gui (push) Has been cancelled
release / check (push) Has been cancelled
release / release (push) Has been cancelled
Docs / build (push) Has been cancelled
Docs / deploy-pages (push) Has been cancelled
Docs / deploy-cloudflare (push) Has been cancelled
326 lines
9.8 KiB
Go
326 lines
9.8 KiB
Go
// Package pack turns a kage mirror on disk into a distributable artifact: a ZIM
|
|
// archive, or a self-contained executable that serves the mirror offline. It is
|
|
// the only pack-side package that touches the filesystem and the running
|
|
// executable; the byte-level format work lives in the zim package.
|
|
package pack
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
|
|
"github.com/tamnd/kage/urlx"
|
|
"github.com/tamnd/kage/zim"
|
|
)
|
|
|
|
// ZIMOptions controls how a mirror is packed into a ZIM archive. Date is passed
|
|
// in from the CLI boundary rather than read from the clock, so the zim and pack
|
|
// packages stay pure and packing the same mirror twice is byte-identical.
|
|
type ZIMOptions struct {
|
|
Out string // output path (default <mirror-base>.zim)
|
|
NoCompress bool // store every cluster raw (code 1)
|
|
Title string // overrides M/Title
|
|
Description string // M/Description
|
|
Language string // M/Language (default "eng")
|
|
Date string // M/Date, e.g. "2026-06-14"
|
|
Version string // kage version, recorded as M/Scraper
|
|
|
|
// CachePath, when set, points at a content-addressed cluster cache sidecar.
|
|
// Compression of unchanged clusters is reused from it, and the cache is
|
|
// rewritten after a successful pack. Empty disables the cache. It has no
|
|
// effect with NoCompress, where nothing is compressed.
|
|
CachePath string
|
|
// Stats, when non-nil, is filled with how many clusters were reused from the
|
|
// cache versus compressed fresh. It lets the caller report incremental gains
|
|
// without changing the function's return signature.
|
|
Stats *PackStats
|
|
}
|
|
|
|
// PackStats reports how a pack reused cached compression. ClustersReused is the
|
|
// number of clusters whose compressed bytes came straight from the cache;
|
|
// ClustersCompressed is the number that were zstd-compressed this run.
|
|
type PackStats struct {
|
|
ClustersReused int
|
|
ClustersCompressed int
|
|
}
|
|
|
|
// BuildZIM walks mirrorDir, turns every file into a C/ content entry, infers the
|
|
// MIME from the extension, picks a main page, adds M/ metadata and a W/mainPage
|
|
// redirect, and writes a .zim to opts.Out. It returns the output path and the
|
|
// number of bytes written.
|
|
func BuildZIM(mirrorDir string, opts ZIMOptions) (string, int64, error) {
|
|
w, cache, err := buildWriter(mirrorDir, opts)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
out := opts.Out
|
|
if out == "" {
|
|
out = filepath.Base(mirrorDir) + ".zim"
|
|
}
|
|
f, err := os.Create(out)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
bw := bufio.NewWriter(f)
|
|
n, err := w.WriteTo(bw)
|
|
if err != nil {
|
|
_ = f.Close()
|
|
return out, n, err
|
|
}
|
|
if err := bw.Flush(); err != nil {
|
|
_ = f.Close()
|
|
return out, n, err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return out, n, err
|
|
}
|
|
if err := persistCache(opts.CachePath, cache); err != nil {
|
|
return out, n, err
|
|
}
|
|
fillStats(opts.Stats, cache)
|
|
return out, n, nil
|
|
}
|
|
|
|
// BuildZIMBytes is the buffer-returning sibling of BuildZIM: it runs the same
|
|
// walk and returns the archive in memory, which the binary path appends to a
|
|
// base executable without writing the ZIM to disk first.
|
|
func BuildZIMBytes(mirrorDir string, opts ZIMOptions) ([]byte, error) {
|
|
w, cache, err := buildWriter(mirrorDir, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var buf bytes.Buffer
|
|
if _, err := w.WriteTo(&buf); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := persistCache(opts.CachePath, cache); err != nil {
|
|
return nil, err
|
|
}
|
|
fillStats(opts.Stats, cache)
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// persistCache writes the cluster cache back to its sidecar after a successful
|
|
// pack. It is a no-op when caching is off.
|
|
func persistCache(path string, c *clusterCache) error {
|
|
if c == nil || path == "" {
|
|
return nil
|
|
}
|
|
return c.save(path)
|
|
}
|
|
|
|
// fillStats copies the cache's reuse counters into the caller's PackStats when
|
|
// both are present, so the CLI can report incremental gains.
|
|
func fillStats(dst *PackStats, c *clusterCache) {
|
|
if dst == nil || c == nil {
|
|
return
|
|
}
|
|
dst.ClustersReused = c.reused
|
|
dst.ClustersCompressed = c.compressed
|
|
}
|
|
|
|
// buildWriter does the shared work of both BuildZIM and BuildZIMBytes: it loads
|
|
// every file under mirrorDir into a zim.Writer with metadata and a main page.
|
|
func buildWriter(mirrorDir string, opts ZIMOptions) (*zim.Writer, *clusterCache, error) {
|
|
info, err := os.Stat(mirrorDir)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, nil, fmt.Errorf("pack: %q is not a directory", mirrorDir)
|
|
}
|
|
|
|
w := zim.NewWriter()
|
|
if opts.NoCompress {
|
|
w.SetNoCompress(true)
|
|
}
|
|
|
|
// The cluster cache only helps when clusters are compressed; with NoCompress
|
|
// there is nothing to reuse, so it is skipped.
|
|
var cache *clusterCache
|
|
if opts.CachePath != "" && !opts.NoCompress {
|
|
cache = loadClusterCache(opts.CachePath)
|
|
w.SetCompress(cache.Compress)
|
|
}
|
|
|
|
skip := urlx.DefaultReserved + "/state.json"
|
|
var htmlPages []string
|
|
counts := map[string]int{}
|
|
|
|
walkErr := filepath.WalkDir(mirrorDir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
rel := slashRel(mirrorDir, p)
|
|
if rel == skip {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mime := MimeForExt(rel)
|
|
title := ""
|
|
if mime == "text/html" {
|
|
htmlPages = append(htmlPages, rel)
|
|
// Store the page's real <title> on its entry, not the URL path, so a
|
|
// ZIM reader's search box and suggestions match the readable title.
|
|
// An empty result leaves the writer to fall back to the url.
|
|
title = htmlTitleOfBytes(data)
|
|
}
|
|
counts[mime]++
|
|
w.AddContent(zim.NamespaceContent, rel, title, mime, data)
|
|
return nil
|
|
})
|
|
if walkErr != nil {
|
|
return nil, nil, walkErr
|
|
}
|
|
|
|
main := pickMainPage(htmlPages)
|
|
if main != "" {
|
|
w.SetMainPage(zim.NamespaceContent, main)
|
|
w.AddRedirect(zim.NamespaceWellKnown, "mainPage", "", zim.NamespaceContent, main)
|
|
}
|
|
|
|
host := filepath.Base(mirrorDir)
|
|
title := firstNonEmpty(opts.Title, htmlTitleOf(mirrorDir, main), host)
|
|
w.AddMetadata("Title", title)
|
|
w.AddMetadata("Name", host)
|
|
w.AddMetadata("Language", firstNonEmpty(opts.Language, "eng"))
|
|
// Description is mandatory metadata in the ZIM spec, so it is always written:
|
|
// the caller's text when given, otherwise a line derived from the host.
|
|
w.AddMetadata("Description", firstNonEmpty(opts.Description, "Offline mirror of "+host+", cloned by kage."))
|
|
w.AddMetadata("Creator", "kage")
|
|
w.AddMetadata("Publisher", "kage")
|
|
if opts.Date != "" {
|
|
w.AddMetadata("Date", opts.Date)
|
|
}
|
|
w.AddMetadata("Scraper", strings.TrimSpace("kage "+opts.Version))
|
|
w.AddMetadata("Source", host)
|
|
w.AddMetadata("Counter", counterString(counts))
|
|
// Illustrator_48x48@1 is the 48x48 PNG favicon Kiwix shows as the archive's
|
|
// icon. When the mirror has no usable icon the archive ships without one.
|
|
if png, ok := Favicon48(mirrorDir); ok {
|
|
w.AddMetadataBytes("Illustrator_48x48@1", "image/png", png)
|
|
}
|
|
return w, cache, nil
|
|
}
|
|
|
|
// pickMainPage chooses the archive's entry point: the root index if present,
|
|
// else the shallowest HTML page, ties broken lexicographically for determinism.
|
|
// It returns "" when the mirror has no HTML at all.
|
|
func pickMainPage(htmlPages []string) string {
|
|
for _, p := range htmlPages {
|
|
if p == "index.html" {
|
|
return p
|
|
}
|
|
}
|
|
sorted := append([]string(nil), htmlPages...)
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
di, dj := strings.Count(sorted[i], "/"), strings.Count(sorted[j], "/")
|
|
if di != dj {
|
|
return di < dj
|
|
}
|
|
return sorted[i] < sorted[j]
|
|
})
|
|
if len(sorted) > 0 {
|
|
return sorted[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// htmlTitleOf reads the main page off disk and returns its <title>, or "" if
|
|
// there is no main page or no title.
|
|
func htmlTitleOf(mirrorDir, mainURL string) string {
|
|
if mainURL == "" {
|
|
return ""
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(mirrorDir, filepath.FromSlash(mainURL)))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return htmlTitleOfBytes(data)
|
|
}
|
|
|
|
// htmlTitleOfBytes returns the first <title> in the given HTML with surrounding
|
|
// and internal whitespace collapsed, or "" if the bytes do not parse or carry
|
|
// no title. Page entries use it so a ZIM reader shows the page's real title
|
|
// instead of its URL path.
|
|
func htmlTitleOfBytes(data []byte) string {
|
|
doc, err := html.Parse(bytes.NewReader(data))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return collapseSpaces(findTitle(doc))
|
|
}
|
|
|
|
// collapseSpaces trims s and replaces every run of whitespace (including the
|
|
// newlines a wrapped <title> can carry) with a single space, so a title reads
|
|
// as one clean line in a reader's library and search results.
|
|
func collapseSpaces(s string) string {
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|
|
|
|
// findTitle returns the text of the first <title> element in depth-first order.
|
|
func findTitle(n *html.Node) string {
|
|
if n.Type == html.ElementNode && n.Data == "title" {
|
|
var b strings.Builder
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
if c.Type == html.TextNode {
|
|
b.WriteString(c.Data)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
if t := findTitle(c); t != "" {
|
|
return t
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// counterString renders the M/Counter value Kiwix uses for stats: a
|
|
// semicolon-separated list of mime=count pairs, sorted for determinism.
|
|
func counterString(counts map[string]int) string {
|
|
mimes := make([]string, 0, len(counts))
|
|
for m := range counts {
|
|
mimes = append(mimes, m)
|
|
}
|
|
sort.Strings(mimes)
|
|
parts := make([]string, len(mimes))
|
|
for i, m := range mimes {
|
|
parts[i] = fmt.Sprintf("%s=%d", m, counts[m])
|
|
}
|
|
return strings.Join(parts, ";")
|
|
}
|
|
|
|
// slashRel returns p relative to root using forward slashes, the form ZIM urls
|
|
// take regardless of the host filesystem separator.
|
|
func slashRel(root, p string) string {
|
|
rel, err := filepath.Rel(root, p)
|
|
if err != nil {
|
|
rel = p
|
|
}
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|