Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7127206
ADFA-3305: Add --types, to optimize only some content types
davidschachterADFA Oct 17, 2026
4e4f29d
ADFA-3305: Add set_content_type_compression.py, to store WebP and Web…
davidschachterADFA Oct 17, 2026
1fe440f
ADFA-3305: Keep animated GIFs' sub-rectangle frames, and try gifsicle…
davidschachterADFA Oct 17, 2026
863dbfd
ADFA-3305: Encode animated GIFs as animated WebP under --webp
davidschachterADFA Oct 17, 2026
1ebe4d2
ADFA-3305: Rewrite an <img> showing a converted animation as a <video>
davidschachterADFA Oct 17, 2026
2f46daa
ADFA-3305: Convert animated GIFs to VP9 WebM under --webm
davidschachterADFA Oct 17, 2026
c060334
ADFA-3305: Ignore a new file's chunk continuations in the rename map
davidschachterADFA Oct 17, 2026
22df310
ADFA-3305: Keep every frame's exact timing in WebM
davidschachterADFA Oct 17, 2026
8a4e535
ADFA-3305: Add rebuild_database.py, to drop the reserved bytes on eve…
davidschachterADFA Oct 17, 2026
9011e9d
ADFA-3305: Add --pack to rebuild_database.py, to fill Content's leaf …
davidschachterADFA Oct 17, 2026
c0f1408
ADFA-3305: Pack Tooltips too, with ids continuing after the old sequence
davidschachterADFA Oct 17, 2026
11f2d86
ADFA-3305: Give a described <video> role="img", so its label is read
davidschachterADFA Oct 17, 2026
57d9ca6
ADFA-3305: Encode WebM with -fflags +bitexact, so reruns match
davidschachterADFA Oct 17, 2026
e745ec3
ADFA-3305: Relink Bookshelf when --pack renumbers Content
davidschachterADFA Oct 17, 2026
389368e
ADFA-3305: Test that the fingerprint sees values, not just rows
davidschachterADFA Oct 17, 2026
eb43fb7
ADFA-3305: Test packed ids past the largest id, not just the sequence
davidschachterADFA Oct 17, 2026
70e311f
ADFA-3305: Choose the indexed WebP palette from frames across the who…
davidschachterADFA Oct 17, 2026
279f221
ADFA-3305: Refuse a rewrite when one filename was converted two ways
davidschachterADFA Oct 17, 2026
f415d6e
ADFA-3305: Refuse a second output for a re-inserted source
davidschachterADFA Oct 17, 2026
d8e9767
ADFA-3305: Pin WebM encoding to 8 threads, so any machine writes the …
davidschachterADFA Oct 17, 2026
2a86583
ADFA-3305: Cap a GIF's WebP loop count at 65,535
davidschachterADFA Oct 17, 2026
d07a511
ADFA-3305: Test that a GIF repeating a set number of times stays out …
davidschachterADFA Oct 17, 2026
e73ab8f
ADFA-3305: Add --page-size to rebuild_database.py, for 4 KB pages
davidschachterADFA Oct 17, 2026
12aa8f0
ADFA-3305: Rename unquoted attribute values when an <img> becomes a <…
davidschachterADFA Oct 18, 2026
0a6affe
ADFA-3305: Test --page-size's limits; reword its help and docstring
davidschachterADFA Oct 18, 2026
5661879
ADFA-3305: Write role=img and aria-hidden=true unquoted
davidschachterADFA Oct 18, 2026
6a4cb1b
ADFA-3305: Match a filename after whitespace that follows = or (
davidschachterADFA Oct 18, 2026
cc09153
ADFA-3305: Answer the second PR #38 review
davidschachterADFA Oct 18, 2026
7ac24e9
ADFA-3305: Say which tables to pack on a schema 3.0.0 database
davidschachterADFA Oct 19, 2026
c947a46
ADFA-3305: Declare the rebuild tests' dependencies for uv run
davidschachterADFA Oct 19, 2026
eef0eed
ADFA-3305: Take templateId 0 as no template, so --pack Templates runs
davidschachterADFA Oct 20, 2026
f7e8633
ADFA-3305: Plan a packed table for the new ids of a table packed with it
davidschachterADFA Oct 20, 2026
1970ebe
ADFA-3305: Pin the template-id bound and the one "none" value in tests
davidschachterADFA Oct 20, 2026
aee470f
ADFA-3305: Warn when a table left out of --pack grows from the new id…
davidschachterADFA Oct 20, 2026
f25cda2
ADFA-3305: Name the app file that reads templateId; rejoin a docstrin…
davidschachterADFA Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
444 changes: 381 additions & 63 deletions scripts/optimize_db_media.py

Large diffs are not rendered by default.

624 changes: 624 additions & 0 deletions scripts/rebuild_database.py

Large diffs are not rendered by default.

182 changes: 182 additions & 0 deletions scripts/set_content_type_compression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = [
# "brotli",
# "Pillow",
# ]
# ///
"""
set_content_type_compression.py

Changes how every Content row of one content type is stored - Brotli or
uncompressed - and records the change in ContentTypes, in one transaction:

uv run scripts/set_content_type_compression.py documentation.db image/webp none
uv run scripts/set_content_type_compression.py documentation.db video/webm none --create

Why the two happen together: a row's compression is not stored with the row.
WebServer.kt, and every tool here, looks it up in ContentTypes.compression for
the row's type, so changing that flag without re-storing the rows would make
every existing row of the type unreadable - and re-storing them without
changing it would do the same.

Why it exists (ADFA-3305): image/webp was marked brotli. WebP is already
entropy-coded; over a production database Brotli saved 0.48% on its 921 rows,
left 654 of them no smaller, and every fetch paid for a full decompress.

The re-store is lossless: each row is decoded with the old setting, stored with
the new one, and decoded again before the commit to check it reads back as the
same content. Running the script again with the old setting reverses it.

--create adds the type when the database has no row for it, as video/webm needs
before the first optimize_db_media.py --webm run. No row can carry a type that
did not exist, so there is nothing to re-store.

--manifest PATH: optimize_db_media.py's manifest records each media file's
STORED size and sha256. Re-storing a row changes both, so an older manifest
would see every re-stored row as edited, and the next optimizer run would
re-encode them - lossy WebP from lossy WebP. Passing the manifest here updates
the entry of every row this run re-stores, provided the entry still described
the row; an entry that already did not match was stale before this run and is
left as it is. The manifest is written before the commit, so a manifest that
cannot be written takes the change down with it.

Backs the database up first (VACUUM INTO a timestamped sibling, like the other
tools), VACUUMs at the end, and supports --dry-run.
"""
from __future__ import annotations

import argparse
import sqlite3
import sys
from pathlib import Path

from optimize_db_media import (
BrotliCodec, backup_database, digest, is_continuation_path, load_dictionary, read_manifest,
reassemble, write_manifest, write_media_row,
)

COMPRESSIONS = ("none", "brotli")


def run(cfg: dict) -> int:
db_path, value, target = cfg["db_path"], cfg["content_type"], cfg["compression"]
if target not in COMPRESSIONS:
print(f"error: compression must be one of {', '.join(COMPRESSIONS)}, not {target!r}", file=sys.stderr)
return 1
if not db_path.is_file():
print(f"error: {db_path} does not exist", file=sys.stderr)
return 1
manifest_path = cfg.get("manifest")
manifest = None
if manifest_path is not None:
try:
manifest = read_manifest(manifest_path)
except (OSError, RuntimeError) as exc:
print(f"error: cannot read --manifest {manifest_path}: {exc}", file=sys.stderr)
return 1

conn = sqlite3.connect(db_path)
try:
row = conn.execute("SELECT id, compression FROM ContentTypes WHERE value = ?", (value,)).fetchone()
if row is None and not cfg.get("create"):
print(f"error: {db_path} has no {value!r} content type; pass --create to add it", file=sys.stderr)
return 1
if row is not None and row[1] == target:
print(f"{value} is already stored as {target}; nothing to do.")
return 0
finally:
conn.close()

if cfg["dry_run"]:
print(f"Dry run: no backup, no changes committed. Re-storing {value} in {db_path} as {target} ...")
else:
print(f"Backing up {db_path} ...")
print(f"Backup written to {backup_database(db_path)}")

warn = lambda message: print(message, file=sys.stderr) # noqa: E731
conn = sqlite3.connect(db_path)
codec = None
restored = manifest_updated = 0
try:
conn.execute("BEGIN")
if row is None:
conn.execute("INSERT INTO ContentTypes (value, compression) VALUES (?, ?)", (value, target))
print(f"Added content type {value} ({target}).")
else:
type_id, current = row
codec = BrotliCodec(load_dictionary(conn))
rows = conn.execute("SELECT path, content, languageID, templateId FROM Content "
"WHERE contentTypeID = ? ORDER BY path", (type_id,)).fetchall()
lengths = {path: len(blob) for path, blob, _language, _template in rows}
for path, blob, language_id, template_id in rows:
if is_continuation_path(lengths, path):
continue # written again as part of its base row
stored = reassemble(conn, path, blob)
raw = codec.decompress(stored, current)
new_stored = codec.compress(raw, target)
write_media_row(conn, path, len(blob), new_stored, language_id, type_id, template_id, warn)
# Read back through the same chunking the server uses, so a
# write that would not reassemble fails here, not on a device.
first = conn.execute("SELECT content FROM Content WHERE path = ?", (path,)).fetchone()[0]
if codec.decompress(reassemble(conn, path, first), target) != raw:
raise RuntimeError(f"{path} did not read back as the same content after re-storing it")
restored += 1
entry = manifest.get(path) if manifest is not None else None
if entry and entry[0] == digest(stored) and entry[1] == len(stored):
manifest[path] = (digest(new_stored), len(new_stored), entry[2])
manifest_updated += 1
conn.execute("UPDATE ContentTypes SET compression = ? WHERE id = ?", (target, type_id))
print(f"Re-stored {restored} {value} row(s) from {current} to {target}.")

if cfg["dry_run"]:
conn.rollback()
else:
if manifest is not None and manifest_updated:
temporary = manifest_path.with_name(manifest_path.name + ".tmp")
try:
write_manifest(temporary, manifest)
temporary.replace(manifest_path)
except OSError as exc:
temporary.unlink(missing_ok=True)
print(f"error: could not update the manifest: {exc}. Rolling back - the rows it "
"describes must not change without it.", file=sys.stderr)
conn.rollback()
return 1
print(f"Updated {manifest_updated} entry(ies) in {manifest_path}.")
conn.commit()
except Exception:
conn.rollback()
raise
finally:
if codec is not None:
codec.close()
conn.close()

if not cfg["dry_run"] and restored:
print("Vacuuming database...")
vac = sqlite3.connect(db_path)
try:
vac.execute("VACUUM")
finally:
vac.close()
return 0


def main() -> None:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("db_path", type=Path, help="SQLite database to change in place, e.g. documentation.db")
p.add_argument("content_type", help="The ContentTypes.value to change, e.g. image/webp")
p.add_argument("compression", help=f"How to store it: {' or '.join(COMPRESSIONS)}")
p.add_argument("--create", action="store_true", help="Add the content type if the database has none")
p.add_argument("--manifest", type=Path, default=None, metavar="PATH",
help="An optimize_db_media.py manifest to keep in step with the re-stored rows")
p.add_argument("--dry-run", action="store_true", help="Do all the work, then roll back without writing")
args = p.parse_args()
sys.exit(run({"db_path": args.db_path, "content_type": args.content_type, "compression": args.compression,
"create": args.create, "manifest": args.manifest, "dry_run": args.dry_run}))


if __name__ == "__main__":
main()
Loading
Loading