code.pablomurad.com

patchwork-turbo

Feeds patchwork with web pages, sites, other feeds, data files, documents, Go APIs and dependencies, written in Go

Track data files and office documents

- datamd: JSON, YAML and CSV become one list item per field, sorted, so
  patchwork's diff reads as field-level changes. Lists of objects are
  keyed by id, slug, key, name or title when one is unique, so adding an
  element doesn't renumber the rest. The revision note names the fields:
  "plans[Pro].price 49 → 59; added plans[Max].price".
- officemd: DOCX and ODT headings, paragraphs, lists and tables. DOCX
  headings come from the style's outline level, which works whatever
  language the style names are in.
- Pages can bring their own change summary; data tracks use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
author
Pablo Murad
date
commit
9e6502c600d7431211c32f85aeac4f43c53695be
parent
e02d11274b9e4896d203133f60b953672f177255
changes
10 files +1057 −13
@@ -0,0 +1,286 @@+// Package datamd turns structured data into one Markdown list item per
+// field, "- path: value", sorted by path. Patchwork then diffs it item by
+// item, which reads as field-level changes: "plans[Pro].price: 49 → 59".
+package datamd
+
+import (
+	"bytes"
+	"encoding/csv"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"path"
+	"sort"
+	"strconv"
+	"strings"
+
+	"gopkg.in/yaml.v3"
+)
+
+// Format guesses the format from a file name or Content-Type: json, yaml
+// or csv, or "" when neither says.
+func Format(name, contentType string) string {
+	ct := strings.ToLower(contentType)
+	switch ext := strings.ToLower(path.Ext(name)); {
+	case ext == ".json" || strings.Contains(ct, "json"):
+		return "json"
+	case ext == ".yaml" || ext == ".yml" || strings.Contains(ct, "yaml"):
+		return "yaml"
+	case ext == ".csv" || strings.Contains(ct, "csv"):
+		return "csv"
+	}
+	return ""
+}
+
+// Convert renders data in the given format ("" tries JSON, then YAML).
+func Convert(raw []byte, format string) (string, error) {
+	fields := map[string]string{}
+	switch format {
+	case "csv":
+		if err := flattenCSV(raw, fields); err != nil {
+			return "", err
+		}
+	case "json":
+		var v any
+		dec := json.NewDecoder(bytes.NewReader(raw))
+		dec.UseNumber() // keeps 1.10 from turning into 1.1
+		if err := dec.Decode(&v); err != nil {
+			return "", err
+		}
+		flatten("", v, fields)
+	case "yaml", "":
+		var v any
+		if err := yaml.Unmarshal(raw, &v); err != nil {
+			if format == "" {
+				return "", errors.New("not JSON, YAML or CSV")
+			}
+			return "", err
+		}
+		flatten("", v, fields)
+	default:
+		return "", fmt.Errorf("unknown data format %q", format)
+	}
+	return Render(fields), nil
+}
+
+// Render writes fields as sorted list items.
+func Render(fields map[string]string) string {
+	keys := make([]string, 0, len(fields))
+	for k := range fields {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	var b strings.Builder
+	for _, k := range keys {
+		b.WriteString("- " + escape(k) + ": " + escape(fields[k]) + "\n")
+	}
+	return b.String()
+}
+
+// keyFields name the elements of a list, when every element has one of
+// them and no two share it. Indexes would shift on every insertion and turn
+// one new element into a change of all the ones after it.
+var keyFields = []string{"id", "slug", "key", "name", "title"}
+
+func flatten(prefix string, v any, out map[string]string) {
+	switch v := v.(type) {
+	case map[any]any:
+		// YAML allows keys that aren't strings.
+		m := make(map[string]any, len(v))
+		for k, x := range v {
+			m[fmt.Sprint(k)] = x
+		}
+		flatten(prefix, m, out)
+	case map[string]any:
+		if len(v) == 0 {
+			out[prefix] = "{}"
+		}
+		for k, x := range v {
+			flatten(join(prefix, k), x, out)
+		}
+	case []any:
+		if len(v) == 0 {
+			out[prefix] = "[]"
+		}
+		key := listKey(v)
+		for i, x := range v {
+			label := strconv.Itoa(i)
+			if key != "" {
+				label = scalar(x.(map[string]any)[key])
+			}
+			flatten(prefix+"["+label+"]", x, out)
+		}
+	default:
+		out[prefix] = scalar(v)
+	}
+}
+
+func listKey(list []any) string {
+	for _, k := range keyFields {
+		seen := map[string]bool{}
+		ok := true
+		for _, x := range list {
+			m, isMap := x.(map[string]any)
+			if !isMap {
+				return ""
+			}
+			v, has := m[k]
+			s := scalar(v)
+			if !has || s == "" || seen[s] {
+				ok = false
+				break
+			}
+			seen[s] = true
+		}
+		if ok {
+			return k
+		}
+	}
+	return ""
+}
+
+func join(prefix, key string) string {
+	if prefix == "" {
+		return key
+	}
+	return prefix + "." + key
+}
+
+func scalar(v any) string {
+	switch v := v.(type) {
+	case nil:
+		return "null"
+	case string:
+		return strings.ReplaceAll(v, "\n", `\n`)
+	case map[string]any, []any:
+		raw, _ := json.Marshal(v) // values decoded from JSON or YAML marshal back
+		return string(raw)
+	}
+	return fmt.Sprint(v)
+}
+
+// flattenCSV keys each row by its first column and each cell by its
+// header: "Pro.price: 59".
+func flattenCSV(raw []byte, out map[string]string) error {
+	r := csv.NewReader(bytes.NewReader(raw))
+	r.FieldsPerRecord = -1
+	rows, err := r.ReadAll()
+	if err != nil {
+		return err
+	}
+	if len(rows) == 0 {
+		return nil
+	}
+	header := rows[0]
+	seen := map[string]int{}
+	for i, row := range rows[1:] {
+		if len(row) == 0 {
+			continue
+		}
+		key := strings.TrimSpace(row[0])
+		if key == "" {
+			key = "row " + strconv.Itoa(i+2)
+		}
+		if n := seen[key]; n > 0 {
+			key += " (" + strconv.Itoa(n+1) + ")"
+		}
+		seen[strings.TrimSpace(row[0])]++
+		for j := 1; j < len(row); j++ {
+			col := "column " + strconv.Itoa(j+1)
+			if j < len(header) && strings.TrimSpace(header[j]) != "" {
+				col = strings.TrimSpace(header[j])
+			}
+			out[key+"."+col] = strings.TrimSpace(row[j])
+		}
+	}
+	return nil
+}
+
+var escaper = strings.NewReplacer(`\`, `\\`, "`", "\\`", "*", `\*`, "_", `\_`, "[", `\[`, "]", `\]`, "<", `\<`)
+
+func escape(s string) string { return escaper.Replace(s) }
+
+var unescaper = strings.NewReplacer(`\\`, `\`, "\\`", "`", `\*`, "*", `\_`, "_", `\[`, "[", `\]`, "]", `\<`, "<")
+
+// Fields reads back a body Render wrote.
+func Fields(body string) map[string]string {
+	fields := map[string]string{}
+	for _, l := range strings.Split(body, "\n") {
+		l, ok := strings.CutPrefix(l, "- ")
+		if !ok {
+			continue
+		}
+		// Keys never contain ": " unescaped at the split point: the first
+		// one ends the key.
+		k, v, ok := strings.Cut(l, ": ")
+		if !ok {
+			continue
+		}
+		fields[unescaper.Replace(k)] = unescaper.Replace(v)
+	}
+	return fields
+}
+
+// maxNamed bounds how many fields a summary names before counting the rest.
+const maxNamed = 5
+
+// Summary describes what changed between two bodies Render wrote, naming
+// the fields: "plans[Pro].price 49 → 59; added plans[Max].price".
+func Summary(before, after string) (string, string) {
+	old, cur := Fields(before), Fields(after)
+	var changed, added, removed []string
+	for k, v := range cur {
+		o, had := old[k]
+		switch {
+		case !had:
+			added = append(added, k)
+		case o != v:
+			changed = append(changed, k+" "+short(o)+" → "+short(v))
+		}
+	}
+	for k := range old {
+		if _, ok := cur[k]; !ok {
+			removed = append(removed, k)
+		}
+	}
+	var parts []string
+	named := 0
+	list := func(label string, items []string) {
+		if len(items) == 0 {
+			return
+		}
+		sort.Strings(items)
+		room := max(maxNamed-named, 0)
+		shown := items[:min(len(items), room)]
+		named += len(shown)
+		s := label + strings.Join(shown, ", ")
+		if rest := len(items) - len(shown); rest > 0 {
+			if len(shown) > 0 {
+				s += " and "
+			}
+			s += strconv.Itoa(rest) + " more"
+		}
+		parts = append(parts, s)
+	}
+	list("", changed)
+	list("added ", added)
+	list("removed ", removed)
+	if len(parts) == 0 {
+		return "fields reordered", ""
+	}
+	kind := ""
+	if len(changed) == 0 && len(removed) == 0 {
+		kind = "expansion"
+	}
+	return strings.Join(parts, "; "), kind
+}
+
+func short(s string) string {
+	if r := []rune(s); len(r) > 40 {
+		return string(r[:37]) + "..."
+	}
+	if s == "" {
+		return `""`
+	}
+	return s
+}
@@ -0,0 +1,102 @@+package datamd
+
+import "testing"
+
+func TestJSON(t *testing.T) {
+	got, err := Convert([]byte(`{
+  "name": "Example",
+  "price": 1.10,
+  "tags": ["a", "b"],
+  "empty": {},
+  "plans": [
+    {"name": "Pro", "price": 49, "features": ["x"]},
+    {"name": "Free", "price": 0, "note": "line one\nline two"}
+  ]
+}`), "json")
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := `- empty: {}
+- name: Example
+- plans\[Free\].name: Free
+- plans\[Free\].note: line one\\nline two
+- plans\[Free\].price: 0
+- plans\[Pro\].features\[0\]: x
+- plans\[Pro\].name: Pro
+- plans\[Pro\].price: 49
+- price: 1.10
+- tags\[0\]: a
+- tags\[1\]: b
+`
+	if got != want {
+		t.Errorf("got\n%s\nwant\n%s", got, want)
+	}
+}
+
+func TestYAMLAndGuess(t *testing.T) {
+	got, err := Convert([]byte("site:\n  title: Moon\n  2026: yes\nitems:\n  - x\n  - y\n"), "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := "- items\\[0\\]: x\n- items\\[1\\]: y\n- site.2026: yes\n- site.title: Moon\n"
+	if got != want {
+		t.Errorf("got\n%s\nwant\n%s", got, want)
+	}
+	if _, err := Convert([]byte("{not: [valid"), ""); err == nil {
+		t.Error("garbage must be an error")
+	}
+}
+
+func TestCSV(t *testing.T) {
+	got, err := Convert([]byte("plan,price,users\nPro,49,5\nFree,0,\nPro,99,10\n"), "csv")
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := "- Free.price: 0\n- Free.users: \n- Pro (2).price: 99\n- Pro (2).users: 10\n- Pro.price: 49\n- Pro.users: 5\n"
+	if got != want {
+		t.Errorf("got\n%s\nwant\n%s", got, want)
+	}
+}
+
+func TestSummary(t *testing.T) {
+	before, _ := Convert([]byte(`{"plans":[{"name":"Pro","price":49},{"name":"Old","price":1}]}`), "json")
+	after, _ := Convert([]byte(`{"plans":[{"name":"Pro","price":59},{"name":"Max","price":99}]}`), "json")
+	s, kind := Summary(before, after)
+	want := "plans[Pro].price 49 → 59; added plans[Max].name, plans[Max].price; removed plans[Old].name, plans[Old].price"
+	if s != want || kind != "" {
+		t.Errorf("got %q %q\nwant %q", s, kind, want)
+	}
+	s, kind = Summary("- a: 1\n", "- a: 1\n- b: 2\n")
+	if s != "added b" || kind != "expansion" {
+		t.Errorf("got %q %q", s, kind)
+	}
+}
+
+func TestSummaryCaps(t *testing.T) {
+	before, after := "", ""
+	for _, k := range []string{"a", "b", "c", "d", "e", "f", "g"} {
+		before += "- " + k + ": 1\n"
+		after += "- " + k + ": 2\n"
+	}
+	after += "- z: 1\n"
+	s, _ := Summary(before, after)
+	want := "a 1 → 2, b 1 → 2, c 1 → 2, d 1 → 2, e 1 → 2 and 2 more; added 1 more"
+	if s != want {
+		t.Errorf("got %q\nwant %q", s, want)
+	}
+}
+
+func TestFormat(t *testing.T) {
+	cases := map[[2]string]string{
+		{"prices.json", ""}:                               "json",
+		{"x.YML", ""}:                                     "yaml",
+		{"https://e.com/api", "text/csv"}:                 "csv",
+		{"https://e.com/api", "application/vnd.api+json"}: "json",
+		{"notes.txt", "text/plain"}:                       "",
+	}
+	for in, want := range cases {
+		if got := Format(in[0], in[1]); got != want {
+			t.Errorf("Format(%q, %q) = %q, want %q", in[0], in[1], got, want)
+		}
+	}
+}
@@ -0,0 +1,210 @@+package officemd
+
+import (
+	"archive/zip"
+	"bytes"
+	"encoding/xml"
+	"strings"
+)
+
+// docx reads word/document.xml. Headings come from the outline level of
+// the paragraph's style, which works whatever language the style names are
+// in.
+func docx(files map[string]*zip.File) (Doc, error) {
+	levels, err := docxLevels(files)
+	if err != nil {
+		return Doc{}, err
+	}
+	raw, err := read(files, "word/document.xml")
+	if err != nil {
+		return Doc{}, err
+	}
+	var (
+		b     builder
+		text  strings.Builder
+		inP   bool
+		inT   bool
+		level int
+		item  = -1
+	)
+	dec := xml.NewDecoder(bytes.NewReader(raw))
+	for {
+		tok, err := dec.Token()
+		if err != nil {
+			break
+		}
+		switch t := tok.(type) {
+		case xml.StartElement:
+			switch t.Name.Local {
+			case "p":
+				inP, level, item = true, 0, -1
+				text.Reset()
+			case "pStyle":
+				level = levels[attr(t, "val")]
+			case "outlineLvl":
+				level = atoi(attr(t, "val")) + 1
+			case "ilvl":
+				item = atoi(attr(t, "val"))
+			case "numId":
+				if item < 0 {
+					item = 0
+				}
+				if attr(t, "val") == "0" {
+					item = -1 // numbering explicitly turned off
+				}
+			case "t":
+				inT = true
+			case "tab":
+				text.WriteByte(' ')
+			case "br", "cr":
+				text.WriteByte('\n')
+			case "tbl":
+				b.startTable()
+			case "tc":
+				b.startCell()
+			}
+		case xml.CharData:
+			if inT && inP {
+				text.Write(t)
+			}
+		case xml.EndElement:
+			switch t.Name.Local {
+			case "t":
+				inT = false
+			case "p":
+				inP = false
+				b.paragraph(text.String(), level, item)
+			case "tc":
+				b.endCell()
+			case "tr":
+				b.endRow()
+			case "tbl":
+				b.endTable()
+			}
+		}
+	}
+	d := Doc{Body: b.String()}
+	if core, err := read(files, "docProps/core.xml"); err == nil && core != nil {
+		d.Title = title(core)
+	}
+	return d, nil
+}
+
+// docxLevels maps style ids to heading levels from word/styles.xml: the
+// style's outline level, or its English name ("heading 2", "Title") when
+// it has none.
+func docxLevels(files map[string]*zip.File) (map[string]int, error) {
+	levels := map[string]int{}
+	raw, err := read(files, "word/styles.xml")
+	if err != nil || raw == nil {
+		return levels, err
+	}
+	dec := xml.NewDecoder(bytes.NewReader(raw))
+	id, name, outline := "", "", -1
+	for {
+		tok, err := dec.Token()
+		if err != nil {
+			break
+		}
+		switch t := tok.(type) {
+		case xml.StartElement:
+			switch t.Name.Local {
+			case "style":
+				id, name, outline = attr(t, "styleId"), "", -1
+			case "name":
+				name = strings.ToLower(attr(t, "val"))
+			case "outlineLvl":
+				outline = atoi(attr(t, "val"))
+			}
+		case xml.EndElement:
+			if t.Name.Local != "style" || id == "" {
+				continue
+			}
+			switch {
+			case outline >= 0 && outline < 9:
+				levels[id] = outline + 1
+			case name == "title":
+				levels[id] = 1
+			case strings.HasPrefix(name, "heading "):
+				levels[id] = atoi(strings.TrimPrefix(name, "heading "))
+			}
+		}
+	}
+	return levels, nil
+}
+
+// odt reads content.xml.
+func odt(files map[string]*zip.File) (Doc, error) {
+	raw, err := read(files, "content.xml")
+	if err != nil {
+		return Doc{}, err
+	}
+	var (
+		b     builder
+		text  strings.Builder
+		depth int // nesting of paragraphs: a note inside a paragraph
+		level int
+		lists int // nesting of text:list
+	)
+	dec := xml.NewDecoder(bytes.NewReader(raw))
+	for {
+		tok, err := dec.Token()
+		if err != nil {
+			break
+		}
+		switch t := tok.(type) {
+		case xml.StartElement:
+			switch t.Name.Local {
+			case "h", "p":
+				if depth == 0 {
+					text.Reset()
+					level = 0
+					if t.Name.Local == "h" {
+						level = max(atoi(attr(t, "outline-level")), 1)
+					}
+				}
+				depth++
+			case "s":
+				n := max(atoi(attr(t, "c")), 1)
+				text.WriteString(strings.Repeat(" ", n))
+			case "tab":
+				text.WriteByte(' ')
+			case "line-break":
+				text.WriteByte('\n')
+			case "list":
+				lists++
+			case "table":
+				b.startTable()
+			case "table-cell":
+				b.startCell()
+			case "note", "annotation":
+				dec.Skip() // footnotes and comments aren't the text
+			}
+		case xml.CharData:
+			if depth > 0 {
+				text.Write(t)
+			}
+		case xml.EndElement:
+			switch t.Name.Local {
+			case "h", "p":
+				depth--
+				if depth == 0 {
+					b.paragraph(text.String(), level, lists-1)
+				}
+			case "list":
+				lists--
+			case "table-cell":
+				b.endCell()
+			case "table-row":
+				b.endRow()
+			case "table":
+				b.endTable()
+			}
+		}
+	}
+	d := Doc{Body: b.String()}
+	if meta, err := read(files, "meta.xml"); err == nil && meta != nil {
+		d.Title = title(meta)
+	}
+	return d, nil
+}
@@ -0,0 +1,232 @@+// Package officemd reads the text of DOCX and ODT documents as Markdown:
+// headings, paragraphs, lists and tables. Formatting, images and comments
+// are left out; a diff of the words is what matters.
+package officemd
+
+import (
+	"archive/zip"
+	"bytes"
+	"encoding/xml"
+	"errors"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+)
+
+// maxPart bounds how much of one file inside the archive is read.
+const maxPart = 20 << 20
+
+// Doc is a converted document.
+type Doc struct {
+	Title string
+	Body  string
+}
+
+// Convert reads a DOCX or ODT file, telling them apart by their contents.
+func Convert(raw []byte) (Doc, error) {
+	zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
+	if err != nil {
+		return Doc{}, fmt.Errorf("not a DOCX or ODT file: %w", err)
+	}
+	files := map[string]*zip.File{}
+	for _, f := range zr.File {
+		files[f.Name] = f
+	}
+	switch {
+	case files["word/document.xml"] != nil:
+		return docx(files)
+	case files["content.xml"] != nil:
+		return odt(files)
+	}
+	return Doc{}, errors.New("not a DOCX or ODT file")
+}
+
+func read(files map[string]*zip.File, name string) ([]byte, error) {
+	f := files[name]
+	if f == nil {
+		return nil, nil
+	}
+	rc, err := f.Open()
+	if err != nil {
+		return nil, err
+	}
+	defer rc.Close()
+	return io.ReadAll(io.LimitReader(rc, maxPart))
+}
+
+// title reads dc:title from DOCX core properties or ODT meta.
+func title(raw []byte) string {
+	dec := xml.NewDecoder(bytes.NewReader(raw))
+	in := false
+	var b strings.Builder
+	for {
+		tok, err := dec.Token()
+		if err != nil {
+			return ""
+		}
+		switch t := tok.(type) {
+		case xml.StartElement:
+			in = t.Name.Local == "title"
+		case xml.CharData:
+			if in {
+				b.Write(t)
+			}
+		case xml.EndElement:
+			if in {
+				return strings.Join(strings.Fields(b.String()), " ")
+			}
+		}
+	}
+}
+
+// builder collects blocks from paragraphs, list items and table cells.
+type builder struct {
+	blocks []string
+	list   []string // consecutive list items, written as one block
+
+	table [][]string // set while inside a table
+	row   []string
+	cells int // depth of cells, for tables inside tables
+	cell  []string
+}
+
+func (b *builder) endList() {
+	if len(b.list) > 0 {
+		b.blocks = append(b.blocks, strings.Join(b.list, "\n"))
+		b.list = nil
+	}
+}
+
+// paragraph adds one paragraph. level > 0 makes a heading; item >= 0 makes
+// a list item at that depth.
+func (b *builder) paragraph(text string, level, item int) {
+	text = clean(text)
+	if b.cells > 0 {
+		if text != "" {
+			b.cell = append(b.cell, text)
+		}
+		return
+	}
+	if text == "" {
+		return
+	}
+	if item >= 0 {
+		b.list = append(b.list, strings.Repeat("  ", item)+"- "+strings.ReplaceAll(text, "\n", " "))
+		return
+	}
+	b.endList()
+	switch {
+	case level > 0:
+		b.blocks = append(b.blocks, strings.Repeat("#", min(level, 6))+" "+strings.ReplaceAll(text, "\n", " "))
+	default:
+		b.blocks = append(b.blocks, escapeStart(text))
+	}
+}
+
+func (b *builder) startTable() {
+	if b.cells > 0 {
+		return // a table in a cell flattens into the cell's text
+	}
+	b.endList()
+	b.table = [][]string{}
+}
+
+func (b *builder) startCell() { b.cells++ }
+
+func (b *builder) endCell() {
+	b.cells--
+	if b.cells == 0 {
+		b.row = append(b.row, strings.ReplaceAll(strings.Join(b.cell, " "), "|", `\|`))
+		b.cell = nil
+	}
+}
+
+func (b *builder) endRow() {
+	if b.cells == 0 && len(b.row) > 0 {
+		b.table = append(b.table, b.row)
+		b.row = nil
+	}
+}
+
+func (b *builder) endTable() {
+	if b.cells > 0 || b.table == nil {
+		return
+	}
+	rows := b.table
+	b.table = nil
+	if len(rows) == 0 {
+		return
+	}
+	cols := 0
+	for _, r := range rows {
+		cols = max(cols, len(r))
+	}
+	var s strings.Builder
+	line := func(r []string) {
+		s.WriteString("|")
+		for i := 0; i < cols; i++ {
+			v := ""
+			if i < len(r) {
+				v = strings.ReplaceAll(r[i], "\n", " ")
+			}
+			s.WriteString(" " + v + " |")
+		}
+		s.WriteString("\n")
+	}
+	line(rows[0])
+	s.WriteString("|" + strings.Repeat(" --- |", cols) + "\n")
+	for _, r := range rows[1:] {
+		line(r)
+	}
+	b.blocks = append(b.blocks, strings.TrimSuffix(s.String(), "\n"))
+}
+
+func (b *builder) String() string {
+	b.endList()
+	return strings.Join(b.blocks, "\n\n")
+}
+
+// clean collapses spaces inside each line and drops empty lines.
+func clean(s string) string {
+	var lines []string
+	for _, l := range strings.Split(s, "\n") {
+		if l = strings.Join(strings.Fields(l), " "); l != "" {
+			lines = append(lines, l)
+		}
+	}
+	return strings.Join(lines, "\n")
+}
+
+// escapeStart keeps a paragraph from starting like Markdown syntax.
+func escapeStart(p string) string {
+	switch p[0] {
+	case '#', '>', '-', '+', '*', '=', '|', '~':
+		return `\` + p
+	}
+	i := 0
+	for i < len(p) && i < 9 && p[i] >= '0' && p[i] <= '9' {
+		i++
+	}
+	if i > 0 && i+1 < len(p) && (p[i] == '.' || p[i] == ')') && p[i+1] == ' ' {
+		return p[:i] + `\` + p[i:]
+	}
+	return p
+}
+
+func attr(e xml.StartElement, local string) string {
+	for _, a := range e.Attr {
+		if a.Name.Local == local {
+			return a.Value
+		}
+	}
+	return ""
+}
+
+func atoi(s string) int {
+	n, err := strconv.Atoi(s)
+	if err != nil {
+		return 0
+	}
+	return n
+}
@@ -0,0 +1,93 @@+package officemd
+
+import (
+	"archive/zip"
+	"bytes"
+	"testing"
+)
+
+func archive(t *testing.T, files map[string]string) []byte {
+	t.Helper()
+	var buf bytes.Buffer
+	zw := zip.NewWriter(&buf)
+	for name, content := range files {
+		w, err := zw.Create(name)
+		if err != nil {
+			t.Fatal(err)
+		}
+		w.Write([]byte(content))
+	}
+	if err := zw.Close(); err != nil {
+		t.Fatal(err)
+	}
+	return buf.Bytes()
+}
+
+const wns = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"`
+
+func TestDOCX(t *testing.T) {
+	raw := archive(t, map[string]string{
+		"word/styles.xml": `<w:styles ` + wns + `>
+<w:style w:styleId="Ttulo1"><w:name w:val="heading 1"/><w:pPr><w:outlineLvl w:val="0"/></w:pPr></w:style>
+<w:style w:styleId="Ttulo2"><w:name w:val="Título 2"/><w:pPr><w:outlineLvl w:val="1"/></w:pPr></w:style>
+<w:style w:styleId="Title"><w:name w:val="Title"/></w:style>
+</w:styles>`,
+		"word/document.xml": `<w:document ` + wns + `><w:body>
+<w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr><w:r><w:t>Handbook</w:t></w:r></w:p>
+<w:p><w:pPr><w:pStyle w:val="Ttulo2"/></w:pPr><w:r><w:t>Leave</w:t></w:r></w:p>
+<w:p><w:r><w:t xml:space="preserve">Everyone gets </w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>30</w:t></w:r><w:r><w:t xml:space="preserve"> days.</w:t></w:r><w:r><w:br/><w:t>Second line.</w:t></w:r></w:p>
+<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="3"/></w:numPr></w:pPr><w:r><w:t>Ask first</w:t></w:r></w:p>
+<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="3"/></w:numPr></w:pPr><w:r><w:t>In writing</w:t></w:r></w:p>
+<w:p><w:r><w:delText>removed in review</w:delText></w:r></w:p>
+<w:tbl><w:tr><w:tc><w:p><w:r><w:t>Role</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>Days</w:t></w:r></w:p></w:tc></w:tr>
+<w:tr><w:tc><w:p><w:r><w:t>Staff</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>30 | more</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
+<w:p><w:r><w:t>- not a list</w:t></w:r></w:p>
+</w:body></w:document>`,
+		"docProps/core.xml": `<cp:coreProperties xmlns:cp="x" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Staff Handbook</dc:title></cp:coreProperties>`,
+	})
+	d, err := Convert(raw)
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := "# Handbook\n\n## Leave\n\nEveryone gets 30 days.\nSecond line.\n\n- Ask first\n  - In writing\n\n| Role | Days |\n| --- | --- |\n| Staff | 30 \\| more |\n\n\\- not a list"
+	if d.Body != want {
+		t.Errorf("got\n%s\nwant\n%s", d.Body, want)
+	}
+	if d.Title != "Staff Handbook" {
+		t.Errorf("title %q", d.Title)
+	}
+}
+
+func TestODT(t *testing.T) {
+	raw := archive(t, map[string]string{
+		"content.xml": `<office:document-content xmlns:office="o" xmlns:text="t" xmlns:table="tb"><office:body><office:text>
+<text:h text:outline-level="2">Leave</text:h>
+<text:p>Everyone<text:s text:c="2"/>gets<text:tab/>30 days.<text:note><text:note-body><text:p>a footnote</text:p></text:note-body></text:note></text:p>
+<text:list><text:list-item><text:p>Ask first</text:p>
+  <text:list><text:list-item><text:p>In writing</text:p></text:list-item></text:list>
+</text:list-item></text:list>
+<table:table><table:table-row><table:table-cell><text:p>Role</text:p></table:table-cell><table:table-cell><text:p>Days</text:p></table:table-cell></table:table-row></table:table>
+</office:text></office:body></office:document-content>`,
+		"meta.xml": `<office:document-meta xmlns:office="o" xmlns:dc="http://purl.org/dc/elements/1.1/"><office:meta><dc:title>Handbook</dc:title></office:meta></office:document-meta>`,
+	})
+	d, err := Convert(raw)
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := "## Leave\n\nEveryone gets 30 days.\n\n- Ask first\n  - In writing\n\n| Role | Days |\n| --- | --- |"
+	if d.Body != want {
+		t.Errorf("got\n%s\nwant\n%s", d.Body, want)
+	}
+	if d.Title != "Handbook" {
+		t.Errorf("title %q", d.Title)
+	}
+}
+
+func TestNotOffice(t *testing.T) {
+	if _, err := Convert([]byte("plain text")); err == nil {
+		t.Error("plain text is not a document")
+	}
+	if _, err := Convert(archive(t, map[string]string{"x.txt": "hi"})); err == nil {
+		t.Error("a zip without a document is not a document")
+	}
+}
@@ -27,11 +27,11 @@ Path  string 	Title string
 	URL   string
 	Body  string
-	// Note describes the change when the converter knows better than a
-	// count of blocks, like "Breaking: removed func X". Kind overrides the
-	// guessed kind the same way.
-	Note string
-	Kind string
+	// Summary describes a change when the converter knows better than a
+	// count of blocks, like "Breaking: removed func X". It gets the old and
+	// new bodies and returns the phrase and the kind of change. Nil means
+	// Compare.
+	Summary func(before, after string) (summary, kind string)
 }
 
 // Writer writes the pages of every track under Dir.
@@ -134,14 +134,12 @@ if prev.body == body && prev.title == p.Title { 		return "", unchanged, nil
 	}
 	summary, kind := "title changed", ""
-	if prev.body != body {
+	switch {
+	case prev.body == body:
+	case p.Summary != nil:
+		summary, kind = p.Summary(prev.body, body)
+	default:
 		summary, kind = Compare(prev.body, body)
-	}
-	if p.Note != "" {
-		summary = p.Note
-	}
-	if p.Kind != "" {
-		kind = p.Kind
 	}
 	note := fmt.Sprintf("Changed on %s: %s.", w.Now().In(w.Loc).Format("2006-01-02 15:04 MST"), strings.TrimSuffix(summary, "."))
 	return note, changed, w.save(file, render(p, body, note, kind))
@@ -84,7 +84,8 @@ func TestConverterNoteWins(t *testing.T) {
 	w := writer(t)
 	w.Write("api", []Page{{Path: "p", Title: "p", Body: "a"}}, nil, false)
-	w.Write("api", []Page{{Path: "p", Title: "p", Body: "b", Note: "Breaking: removed func X.", Kind: "position"}}, nil, false)
+	sum := func(before, after string) (string, string) { return "Breaking: removed func X.", "position" }
+	w.Write("api", []Page{{Path: "p", Title: "p", Body: "b", Summary: sum}}, nil, false)
 	got := read(t, w, "api", "p")
 	if !strings.Contains(got, "UTC: Breaking: removed func X.") || !strings.Contains(got, "revision_kind: position") {
 		t.Errorf("got\n%s", got)
@@ -0,0 +1,99 @@+package track
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/config"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/datamd"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/fetch"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/officemd"
+	"git.pablomurad.com/pablo/patchwork-turbo/internal/out"
+)
+
+// source is a file read from disk or fetched over HTTP.
+type source struct {
+	name        string // file name, for guessing the format and the title
+	url         string // empty for local files
+	contentType string
+	body        []byte
+}
+
+// load reads a track's file. A nil source with no error means a 304: the
+// file kept on disk is still current.
+func (r *Runner) load(ctx context.Context, t config.Track, src string) (*source, error) {
+	if !isURL(src) {
+		body, err := os.ReadFile(src)
+		if err != nil {
+			return nil, err
+		}
+		return &source{name: filepath.Base(src), body: body}, nil
+	}
+	resp, err := r.Fetch.Get(ctx, src, r.Writer.Has(t.Name, "index"))
+	if err != nil || resp.NotModified {
+		return nil, err
+	}
+	name := path.Base(resp.URL)
+	if i := strings.IndexAny(name, "?#"); i >= 0 {
+		name = name[:i]
+	}
+	return &source{name: name, url: resp.URL, contentType: resp.ContentType, body: resp.Body}, nil
+}
+
+// data tracks a JSON, YAML or CSV file, field by field.
+func (r *Runner) data(ctx context.Context, t config.Track) (Result, error) {
+	res := Result{Complete: true}
+	s, err := r.load(ctx, t, t.Data)
+	if err != nil && fetch.Gone(err) {
+		res.warn("%v; page removed", err)
+		return res, nil
+	}
+	if err != nil {
+		return Result{}, err
+	}
+	if s == nil {
+		res.Keep = []string{"index"}
+		return res, nil
+	}
+	body, err := datamd.Convert(s.body, datamd.Format(s.name, s.contentType))
+	if err != nil {
+		return Result{}, fmt.Errorf("%s: %w", t.Data, err)
+	}
+	res.Pages = []out.Page{{Path: "index", Title: s.name, URL: s.url, Body: body, Summary: datamd.Summary}}
+	return res, nil
+}
+
+// doc tracks a DOCX or ODT document.
+func (r *Runner) doc(ctx context.Context, t config.Track) (Result, error) {
+	res := Result{Complete: true}
+	s, err := r.load(ctx, t, t.Doc)
+	if err != nil && fetch.Gone(err) {
+		res.warn("%v; page removed", err)
+		return res, nil
+	}
+	if err != nil {
+		return Result{}, err
+	}
+	if s == nil {
+		res.Keep = []string{"index"}
+		return res, nil
+	}
+	d, err := officemd.Convert(s.body)
+	if err != nil {
+		return Result{}, fmt.Errorf("%s: %w", t.Doc, err)
+	}
+	title := d.Title
+	if title == "" {
+		title = strings.TrimSuffix(s.name, path.Ext(s.name))
+	}
+	res.Pages = []out.Page{{Path: "index", Title: title, URL: s.url, Body: d.Body}}
+	return res, nil
+}
+
+func isURL(s string) bool {
+	return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
+}
@@ -71,6 +71,10 @@ case config.KindSitemap: 		return r.sitemap(ctx, t)
 	case config.KindFeed:
 		return r.feed(ctx, t)
+	case config.KindData:
+		return r.data(ctx, t)
+	case config.KindDoc:
+		return r.doc(ctx, t)
 	}
 	return Result{}, fmt.Errorf("track %q: %s tracks are not implemented yet", t.Name, t.Kind())
 }
@@ -207,3 +207,22 @@ if _, rep, _ := r.Run(ctx, tr); rep.Removed != 0 || !r.Writer.Has("blog", "posts/one") { 		t.Errorf("items that leave the feed keep their file: %+v", rep)
 	}
 }
+
+func TestDataTrack(t *testing.T) {
+	dir := t.TempDir()
+	src := filepath.Join(dir, "prices.json")
+	os.WriteFile(src, []byte(`{"plans":[{"name":"Pro","price":49}]}`), 0o644)
+	tr := config.Track{Name: "prices", Data: src, MaxPages: config.DefaultMaxPages}
+	r, _ := runner(t, nil, tr)
+	ctx := context.Background()
+	if _, rep, err := r.Run(ctx, tr); err != nil || rep.New != 1 {
+		t.Fatalf("first run: %+v %v", rep, err)
+	}
+	os.WriteFile(src, []byte(`{"plans":[{"name":"Pro","price":59}]}`), 0o644)
+	if _, rep, err := r.Run(ctx, tr); err != nil || rep.Changed != 1 {
+		t.Fatalf("second run: %+v %v", rep, err)
+	}
+	if got := file(t, r, "prices", "index"); !strings.Contains(got, "plans[Pro].price 49 → 59") {
+		t.Errorf("file:\n%s", got)
+	}
+}