Files
kage/pack/zim.go
T
Duc-Tam Nguyen e126968c65 Check the deferred Close calls so the linter is happy
errcheck flagged six naked defer Close() calls in the pack code and its
tests. Wrap them in the same defer func() { _ = x.Close() }() form the rest
of the tree already uses.
2026-06-15 00:02:48 +07:00

245 lines
6.5 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
}
// 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, 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
}
return out, n, f.Close()
}
// 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, err := buildWriter(mirrorDir, opts)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if _, err := w.WriteTo(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// 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, error) {
info, err := os.Stat(mirrorDir)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("pack: %q is not a directory", mirrorDir)
}
w := zim.NewWriter()
if opts.NoCompress {
w.SetNoCompress(true)
}
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)
if mime == "text/html" {
htmlPages = append(htmlPages, rel)
}
counts[mime]++
w.AddContent(zim.NamespaceContent, rel, "", mime, data)
return nil
})
if walkErr != nil {
return 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("Language", firstNonEmpty(opts.Language, "eng"))
if opts.Description != "" {
w.AddMetadata("Description", opts.Description)
}
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))
return w, 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 ""
}
f, err := os.Open(filepath.Join(mirrorDir, filepath.FromSlash(mainURL)))
if err != nil {
return ""
}
defer func() { _ = f.Close() }()
doc, err := html.Parse(f)
if err != nil {
return ""
}
return strings.TrimSpace(findTitle(doc))
}
// 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 ""
}