src/flat-html-generator.lua

4746 lines

1#!/usr/bin/env lua
2
3-- Core flat HTML page generation system for neocities-modernization
4-- Generates 13,680+ pages with similarity/diversity ranking in compiled.txt format
5
6-- {{{ local function setup_dir_path
7local function setup_dir_path(provided_dir)
8 if provided_dir then
9 return provided_dir
10 end
11 return "/mnt/mtwo/programming/ai-stuff/neocities-modernization"
12end
13-- }}}
14
15-- Script configuration - handle args properly to avoid -I interfering with DIR
16local DIR = setup_dir_path()
17if arg then
18 for _, arg_val in ipairs(arg) do
19 if arg_val ~= "-I" and not arg_val:match("^%-") then
20 DIR = arg_val
21 break
22 end
23 end
24end
25
26-- Load required libraries
27package.path = DIR .. "/libs/?.lua;" .. DIR .. "/src/?.lua;" .. package.path
28local utils = require("utils")
29local dkjson = require("dkjson")
30-- Issue 8-056: Shared text formatting module for whitespace preservation
31local text_formatter = require("text-formatter")
32-- Shared in-place progress bar (same look + TTY/--debug rules as the GPU stages)
33local progress = require("progress-display")
34-- Issue 9-013: render ranked image entries (pseudo-poems) as image boxes
35local image_render = require("image-render")
36-- Issue 11-005: the explore pages read their prose from editable input/pages/*.txt
37-- files; this fills the {MARKER} placeholders with the live numbers.
38local page_template = require("page-template")
39
40-- Issue 10-003: Load unified config from config.lua
41local config_loader = require("config-loader")
42config_loader.set_project_root(DIR)
43local unified_config = config_loader.load()
44
45-- inference-server-config tells us which embedding model the rest of the pipeline is
46-- pointed at. We use it to derive the cache-directory name in the two
47-- diversity/similarity loader fallbacks below; previously those defaulted
48-- to "embeddinggemma:latest" as a literal string, which silently broke
49-- after a model swap.
50local inference_config = require("inference-server-config")
51inference_config.set_project_root(DIR)
52
53-- Initialize asset path configuration (CLI --dir takes precedence over config)
54utils.init_assets_root(arg)
55
56-- Load effil for parallel processing (optional - falls back to single-threaded if unavailable)
57-- CRITICAL: effil.so is a C library, must be in cpath not path
58package.cpath = package.cpath .. ';/home/ritz/programming/ai-stuff/libs/lua/effil-jit/build/?.so'
59local effil = nil
60local has_threading = false
61
62local success, err = pcall(function()
63 effil = require('effil')
64 has_threading = true
65end)
66
67-- Issue 10-034: Orchestrator message types for lazy loading parallel HTML generation
68-- Main thread acts as cache server, sending 80KB work slices instead of workers loading 700MB
69local MSG_REQUEST_WORK = "get_work" -- Worker → Main: "give me a poem to process"
70local MSG_WORK_SLICE = "work" -- Main → Worker: poem_index + rankings
71local MSG_WORK_DONE = "done" -- Worker → Main: "finished poem X"
72local MSG_SHUTDOWN = "shutdown" -- Main → Worker: "no more work, exit"
73
74local M = {}
75
76-- Mock color assignment for testing (until we have real embeddings)
77local MOCK_POEM_COLORS = {
78 [1] = "blue", -- Introduction post
79 [2] = "purple", -- Philosophy/metaphysics
80 [3] = "red", -- Passion/energy
81 [5] = "orange", -- Programming/technical
82 [4625] = "red", -- Politics/passion
83 [4626] = "gray", -- Short post
84 [4624] = "green" -- Hope/future themes
85}
86
87-- Color configuration for progress bars
88local COLOR_CONFIG = {
89 red = "#dc3c3c",
90 blue = "#3c78dc",
91 green = "#3cb45a",
92 purple = "#8c3cc8",
93 orange = "#e68c3c",
94 yellow = "#c8b428",
95 gray = "#787878"
96}
97
98-- Issue 8-057: Boost visual formatting color scheme
99-- Based on /notes/boost post image style.png design reference
100local BOOST_COLOR_CONFIG = {
101 arrow = "#dc3c3c", -- Red/Magenta: ◀─ and ─▶ arrows, [BOOST] label
102 outer_frame = "#3c78dc", -- Blue/Navy: ╔═╗║╚═╝ outer frame
103 inner_box = "#2aa198", -- Teal/Cyan: ┌─┐│└─┘ inner content box
104 content_text = "#c8b428" -- Yellow: The actual boosted text content
105}
106
107-- The boost frame is drawn by ONE shared module so the main + worker + word-page
108-- copies cannot drift (they had: misaligned walls, wrong junction columns, ▢
109-- corruption). See src/boost-bars.lua + src/boost-bars.test.lua.
110local boost_bars = require("boost-bars")
111boost_bars.configure(BOOST_COLOR_CONFIG)
112
113-- {{{ Issue 16-010: Monospace font enforcement
114-- Font stack prioritizes Hack Nerd Font (user's preference), then falls back
115-- to other popular monospace fonts for consistent rendering across browsers.
116-- Uses CSS font-stack approach (no external font files required).
117local FONT_STYLE = [[
118<style>
119body, pre {
120 font-family: 'Hack Nerd Font', 'Hack', 'Fira Code', 'JetBrains Mono',
121 'Cascadia Code', 'Consolas', 'Monaco', 'Liberation Mono',
122 'Courier New', monospace;
123}
124/* True page-centering for the poem column. The old <table align="center">
125 shrink-wrapped to its WIDEST line -- and an attached image (up to 800px) is
126 wider than the ~84-char text frame, so the cell stretched and the frames
127 hugged the left of that wide cell, landing the whole column left-of-center.
128 Fix: the cell centers its children, each <pre> is an inline-block that
129 centers as a block (text stays left-aligned inside), and media centers via
130 auto margins. Now a vertical line down the page bisects every poem AND image,
131 regardless of how wide any single image is. */
132td { text-align: center; }
133pre { display: inline-block; text-align: left; margin: 0 auto; }
134img, video, audio { margin-left: auto; margin-right: auto; }
135</style>
136]]
137-- }}}
138
139-- Pagination configuration defaults
140-- Issue 10-003: These values are overridden by unified config (config.lua) if present
141-- See Issue 8-020 for hybrid pagination strategy (45GB storage constraint)
142-- Issue 9-003 Fix F: Added chronological pagination settings
143local PAGINATION_CONFIG = {
144 poems_per_page = 100,
145 minimum_pages = 1,
146 -- COMPUTED per build by compute_storage_max_pages (Issue 10-057), not a config
147 -- value. This placeholder is only the operative value if that computation is
148 -- skipped (e.g. a caller that loads pagination just for chronological mapping);
149 -- kept finite so %d logging and math.min() stay well-defined.
150 max_pages_per_poem = 9999,
151 page_number_padding = 2,
152 generate_txt_exports = true,
153 generate_html_archives = false, -- Disabled: redundant with paginated pages
154 chronological_paginated = false, -- Set to true to split chronological.html into multiple pages
155 chronological_poems_per_page = 500 -- Poems per page when chronological_paginated is true
156}
157
158-- Storage configuration (for display purposes)
159-- Issue 10-003: Loaded from unified config (config.lua) if present
160local STORAGE_CONFIG = {
161 limit_gb = 45,
162 reserved_for_maze_gb = 0.031,
163 reserved_headroom_gb = 5
164}
165
166-- Layout constants: Single source of truth for box widths and positions
167-- Issue 8-037: Centralized to prevent drift between calculations
168-- Issue 10-003: Values can be overridden in unified config (config.lua) "layout" section
169-- Reference: All progress bars, nav boxes, and content should use these
170local LAYOUT = {
171 -- Total visible width for regular poems (positions 0-82)
172 REGULAR_POEM_WIDTH = 82,
173 -- Total visible width for golden poems: 84 chars
174 -- Structure: ╔ (1) + interior (82) + ┐ (1) = 84
175 GOLDEN_POEM_WIDTH = 84,
176 -- Maximum text content width (80 chars by default +1 space padding on left and +1 on right)
177 TEXT_CONTENT_WIDTH = 80,
178
179 -- Regular poem nav box positions (within 83-char line):
180 -- ┌─────────┐ ┌───────────┐
181 -- positions: 0-10 = left box (11 chars), 11-69 = gap (59 chars), 70-82 = right box (13 chars)
182 REGULAR_LEFT_BOX_WIDTH = 11, -- ┌─────────┐
183 REGULAR_RIGHT_BOX_WIDTH = 13, -- ┌───────────┐
184 REGULAR_GAP_WIDTH = 59, -- 83 - 11 - 13 = 59
185 REGULAR_LEFT_JUNCTION_POS = 10, -- Position of ┐/┴ under left box
186 REGULAR_RIGHT_JUNCTION_POS = 70, -- Position of ┌/┴ under right box
187
188 -- Golden poem nav box positions (within 84-char line):
189 -- Structure: ║ (1) + content (80) + space (1) + │ (1) = 83 interior + corners
190 GOLDEN_LEFT_BOX_WIDTH = 11,
191 GOLDEN_RIGHT_BOX_WIDTH = 13,
192 GOLDEN_GAP_WIDTH = 58, -- 84 - 2 corners - 11 - 13 = 58
193 -- Issue 8-055: Fixed junction positions to align ╧/┴ under ┐/┌ corners
194 GOLDEN_LEFT_JUNCTION_POS = 10, -- Same as regular (left box ┐ at position 10)
195 GOLDEN_RIGHT_JUNCTION_POS = 71, -- Regular + 1 (right box ┌ at position 71 due to wider golden)
196}
197
198-- {{{ function load_layout_from_config
199-- Issue 10-003: Loads layout settings from unified config, with fallback to LAYOUT defaults
200local function load_layout_from_config()
201 local layout = unified_config.layout
202 if not layout then return end
203
204 -- Override LAYOUT values from config
205 if layout.regular_poem_width then LAYOUT.REGULAR_POEM_WIDTH = layout.regular_poem_width end
206 if layout.golden_poem_width then LAYOUT.GOLDEN_POEM_WIDTH = layout.golden_poem_width end
207 if layout.text_content_width then LAYOUT.TEXT_CONTENT_WIDTH = layout.text_content_width end
208 if layout.left_box_width then
209 LAYOUT.REGULAR_LEFT_BOX_WIDTH = layout.left_box_width
210 LAYOUT.GOLDEN_LEFT_BOX_WIDTH = layout.left_box_width
211 end
212 if layout.right_box_width then
213 LAYOUT.REGULAR_RIGHT_BOX_WIDTH = layout.right_box_width
214 LAYOUT.GOLDEN_RIGHT_BOX_WIDTH = layout.right_box_width
215 end
216 if layout.gap_width then
217 LAYOUT.REGULAR_GAP_WIDTH = layout.gap_width
218 LAYOUT.GOLDEN_GAP_WIDTH = layout.gap_width
219 end
220 if layout.left_junction_pos then LAYOUT.REGULAR_LEFT_JUNCTION_POS = layout.left_junction_pos end
221 if layout.right_junction_pos then LAYOUT.REGULAR_RIGHT_JUNCTION_POS = layout.right_junction_pos end
222end
223-- }}}
224
225-- Load layout from config on module initialization
226load_layout_from_config()
227
228-- Diversity cache (pre-computed GPU sequences for fast HTML generation)
229-- Loaded from assets/embeddings/embeddinggemma_latest/diversity_cache.json
230local DIVERSITY_CACHE = nil
231
232-- Similarity rankings cache (pre-sorted similarity rankings for fast HTML generation)
233-- Loaded from assets/embeddings/embeddinggemma_latest/similarity_rankings_cache.json
234local SIMILARITY_RANKINGS_CACHE = nil
235
236-- {{{ local function load_diversity_cache
237-- Loads pre-computed diversity sequences from GPU cache (required for HTML generation)
238-- Errors out if cache doesn't exist - no fallback to on-the-fly computation
239local function load_diversity_cache(model_name)
240 model_name = model_name or inference_config.get_selected_model()
241 local model_dir = model_name:gsub(":", "_")
242 -- Issue 10-054: diversity stays on disk (embeddings_dir_disk).
243 local cache_file = utils.embeddings_dir_disk(model_name) .. "/diversity_cache.json"
244
245 if not utils.file_exists(cache_file) then
246 error(string.format([[
247Diversity cache not found: %s
248
249The diversity cache is required for HTML generation.
250Generate it with: ./run.sh --generate-diversity
251
252This takes ~1 minute with GPU (or ~42 hours with CPU using --cpu-only).
253]], cache_file))
254 end
255
256 utils.log_info("Loading diversity cache from: " .. cache_file)
257 local cache_data = utils.read_json_file(cache_file)
258
259 if not cache_data then
260 error("Failed to parse diversity cache JSON file")
261 end
262
263 if not cache_data.sequences then
264 error("Diversity cache has invalid format (missing sequences table)")
265 end
266
267 DIVERSITY_CACHE = cache_data
268 return cache_data
269end
270-- }}}
271
272-- {{{ local function load_similarity_rankings_cache
273-- Loads pre-sorted similarity rankings from cache (required for HTML generation)
274-- Errors out if cache doesn't exist - no fallback to on-the-fly sorting
275local function load_similarity_rankings_cache(model_name)
276 model_name = model_name or inference_config.get_selected_model()
277 local model_dir = model_name:gsub(":", "_")
278 -- Issue 10-054: similarity ranking cache is movable (embeddings_dir, RAM).
279 local cache_file = utils.embeddings_dir(model_name) .. "/similarity_rankings_cache.json"
280
281 if not utils.file_exists(cache_file) then
282 error(string.format([[
283Similarity rankings cache not found: %s
284
285The similarity rankings cache is required for fast HTML generation.
286Generate it with: ./run.sh --generate-similarity
287
288This is a post-processing step that pre-sorts similarity rankings.
289]], cache_file))
290 end
291
292 utils.log_info("Loading similarity rankings cache from: " .. cache_file)
293 local cache_data = utils.read_json_file(cache_file)
294
295 if not cache_data then
296 error("Failed to parse similarity rankings cache JSON file")
297 end
298
299 if not cache_data.rankings then
300 error("Similarity rankings cache has invalid format (missing rankings table)")
301 end
302
303 -- Count rankings (for logging)
304 local count = 0
305 for _ in pairs(cache_data.rankings) do count = count + 1 end
306
307 -- Validate cache is not empty (Issue: empty cache generated by standalone script)
308 if count == 0 then
309 error(string.format([[
310Similarity rankings cache is empty (0 poems): %s
311
312This usually means the cache was generated before similarity files existed,
313or the standalone script encountered a path issue.
314
315To fix, regenerate with: ./run.sh --generate-similarity --force
316
317This will regenerate both similarity files AND the rankings cache.
318]], cache_file))
319 end
320
321 SIMILARITY_RANKINGS_CACHE = cache_data
322 return cache_data
323end
324-- }}}
325
326-- {{{ local function media_href
327-- Where a file lives under output/media/, url-encoded for an <img src>/href.
328-- Art images (path under input/images/<source>/...) KEEP their source + subdir
329-- structure -- their human basenames collide (e.g. my-art/x.png vs
330-- my-art/game-design/x.png) and a flat output/media/<basename> would let one
331-- overwrite the other. Mastodon attachments (hashes, NOT under input/images/)
332-- keep just the basename. This MUST match flatten_media_files' target layout and
333-- image-render.lua's copy of this rule, or the src points at the wrong file.
334-- Slashes preserved; space / ? / # / % percent-encoded.
335local function media_href(path)
336 path = path or ""
337 local sub = path:match("input/images/(.+)$") or (path:match("([^/]+)$") or path)
338 return (sub:gsub("[^%w%-%._~/]", function(c)
339 return string.format("%%%02X", string.byte(c))
340 end))
341end
342-- }}}
343
344-- {{{ local function flatten_media_files
345-- Issue 8-048: Copy every configured image into output/media/ for easy deploy.
346-- TWO layouts, by species (kept in lockstep with media_href in the renderers):
347-- * Mastodon media: collapse the ~7-level content-addressed nesting to the
348-- bare hash basename (output/media/abc.png) -- unique already.
349-- * Art images (input/images/<source>/...): keep <source>/<subpath>
350-- (output/media/my-art/game-design/x.png), because human-given basenames
351-- collide across subdirs and a flat layout silently dropped the duplicates.
352-- Called once at start of HTML generation; skips files that already exist (idempotent)
353local media_flattening_done = false
354
355local function flatten_media_files(output_dir)
356 -- Skip if already done this session (idempotent)
357 if media_flattening_done then
358 return true
359 end
360
361 -- The configured image sources are the source of truth for where to
362 -- look. Each entry has an internal project-relative path (where the
363 -- sync script drops files) and may also have an external source path
364 -- (where the operator's actual files live on the wider file system).
365 -- We prefer the internal path when present, and fall back to the
366 -- external source so a configured-but-not-yet-synced entry still
367 -- contributes media. A configured entry that is missing from both
368 -- is a warning, not a fatal error — operators may legitimately
369 -- declare more sources than are populated at any given moment.
370 local sources_loader = require("sources-loader")
371 sources_loader.set_project_root(DIR)
372 local image_dirs = sources_loader.get_directories_with_external("images")
373
374 if not image_dirs or #image_dirs == 0 then
375 utils.log_warn("No image sources configured in sources.images.directories; skipping media flattening")
376 media_flattening_done = true
377 return true
378 end
379
380 local target_dir = output_dir .. "/media"
381 os.execute('mkdir -p "' .. target_dir .. '"')
382
383 local copied = 0
384 local skipped = 0
385 local errors = 0
386 local sources_used = 0
387
388 for _, dir in ipairs(image_dirs) do
389 -- sources-loader's resolve_path already returns an ABSOLUTE path (it
390 -- prepends the project root to relative config entries), so use dir.path
391 -- directly. Prepending DIR again produced a doubled "/root//root/..." path
392 -- that never resolved, so every source looked "missing" -- which the
393 -- mandatory-source check below then turned into a fatal stage-9 failure.
394 local internal_path = dir.path
395 local external_path = dir.external and dir.external.source or nil
396 local resolved_path = nil
397
398 local internal_test = io.open(internal_path, "r")
399 if internal_test then
400 internal_test:close()
401 resolved_path = internal_path
402 elseif external_path then
403 local external_test = io.open(external_path, "r")
404 if external_test then
405 external_test:close()
406 resolved_path = external_path
407 end
408 end
409
410 if not resolved_path then
411 -- Every configured image source is mandatory (the "optional" concept was
412 -- removed): a missing source means media we expected to ship is absent,
413 -- so we fail loudly here rather than silently skip it. Fix it by running
414 -- the sync/extraction that populates the path, or remove the source from
415 -- config.lua if it is genuinely gone.
416 error(string.format(
417 "Image source '%s' not found at internal '%s'%s -- every source is required; sync/extract it or remove it from config.lua",
418 dir.name or "(unnamed)",
419 dir.path or "(no path)",
420 external_path and (" or external '" .. external_path .. "'") or ""))
421 else
422 sources_used = sources_used + 1
423
424 -- Find every file under the resolved source and place it under
425 -- output/media/. TWO species, two layouts (must match media_href in
426 -- the renderers exactly, or the <img src> points at the wrong file):
427 -- * art sources (path .../input/images/<source>): keep
428 -- <source>/<subpath>, so two files that share a basename in
429 -- different subdirs (e.g. my-art/x.png and my-art/game-design/x.png)
430 -- stay distinct instead of one silently overwriting the other.
431 -- * everything else (Mastodon media, content-addressed hashes):
432 -- flatten to the bare basename -- already unique, and this
433 -- collapses the ~7-level Mastodon nesting.
434 -- No leading-^ anchor: dir.path is absolute (see above), so we match the
435 -- "input/images/<rest>" tail wherever it appears -- the same tail
436 -- media_href() extracts in the renderers, keeping the two layouts identical.
437 local ns_prefix = dir.path and dir.path:match("input/images/(.+)$") or nil
438 local find_cmd = string.format('find "%s" -type f', resolved_path)
439 local handle = io.popen(find_cmd)
440 if handle then
441 for source_path in handle:lines() do
442 -- this file's path within its own source dir (art subdirs kept)
443 local within = source_path:sub(#resolved_path + 2)
444 local target_sub
445 if ns_prefix then
446 target_sub = ns_prefix .. "/" .. within
447 else
448 target_sub = source_path:match("([^/]+)$")
449 end
450 if target_sub and target_sub ~= "" then
451 local target_path = target_dir .. "/" .. target_sub
452 local exists_check = io.open(target_path, "r")
453 if exists_check then
454 exists_check:close()
455 skipped = skipped + 1
456 else
457 -- create the subdirectory before copying (art paths
458 -- now nest one or more levels under output/media/)
459 local parent = target_path:match("^(.*)/[^/]+$")
460 if parent then os.execute('mkdir -p "' .. parent .. '"') end
461 local cp_cmd = string.format('cp "%s" "%s"', source_path, target_path)
462 local success = os.execute(cp_cmd)
463 if success == 0 or success == true then
464 copied = copied + 1
465 else
466 errors = errors + 1
467 utils.log_warn("Failed to copy: " .. source_path)
468 end
469 end
470 end
471 end
472 handle:close()
473 else
474 utils.log_warn("Could not scan image source: " .. resolved_path)
475 end
476 end
477 end
478
479 utils.log_info(string.format(
480 "Media flattening: %d sources used | %d copied, %d skipped, %d errors",
481 sources_used, copied, skipped, errors))
482
483 media_flattening_done = true
484 return errors == 0
485end
486-- }}}
487
488-- {{{ local function load_pagination_config
489-- Issue 10-003: Loads pagination and storage settings from unified config
490-- Updated for Issue 8-020: Hybrid pagination strategy with storage constraints
491-- Note: Only loads and logs once per session (idempotent)
492local pagination_config_loaded = false
493
494local function load_pagination_config()
495 -- Skip if already loaded (idempotent)
496 if pagination_config_loaded then
497 return PAGINATION_CONFIG
498 end
499
500 -- Load pagination settings from unified config
501 if unified_config.pagination then
502 for key, value in pairs(unified_config.pagination) do
503 if key ~= "_comment" and PAGINATION_CONFIG[key] ~= nil then
504 PAGINATION_CONFIG[key] = value
505 end
506 end
507 end
508
509 -- Load storage settings from unified config (Issue 8-020)
510 if unified_config.storage then
511 for key, value in pairs(unified_config.storage) do
512 if key ~= "_comment" and STORAGE_CONFIG[key] ~= nil then
513 STORAGE_CONFIG[key] = value
514 end
515 end
516 end
517
518 pagination_config_loaded = true
519 return PAGINATION_CONFIG
520end
521-- }}}
522
523-- {{{ local function compute_storage_max_pages
524-- Issue 10-057 follow-up: derive how many similar/different pages per poem fit the
525-- storage quota instead of freezing a guess in config. Everything is MEASURED from
526-- the last build's output on disk -- a self-correcting validator, not an estimate:
527-- budget = storage.limit_gb (the Neocities quota; the one real config fact)
528-- avg_page_size = bytes of output/similar / number of those page files
529-- per_page_level = avg_page_size x num_poems x 2 (each poem gets one similar AND
530-- one different page per page-level)
531-- fixed = everything else already in output/ (media, wordcloud, chrono,
532-- gallery) -- does NOT grow with the page count
533-- max_pages = floor((budget - fixed) / per_page_level)
534-- Pages reference images via <img src>, so a page on disk is text; the picture bytes
535-- are the single output/media cost, folded into `fixed`. Measurements use du/find
536-- (read-only) with block-rounded bytes -- conservative (rounds the cap DOWN, the safe
537-- direction for a quota). First build (no pages to measure): warn and DO NOT cap; the
538-- next build measures real sizes and applies the cap.
539local function compute_storage_max_pages(output_dir, num_poems)
540 local function popen_num(cmd)
541 local h = io.popen(cmd)
542 if not h then return nil end
543 local out = h:read("*a"); h:close()
544 return tonumber((out or ""):match("(%d+)"))
545 end
546 local function dir_bytes(path)
547 return popen_num(string.format("du -s --block-size=1 %q", path)) or 0
548 end
549
550 local sim_dir = output_dir .. "/similar"
551 local diff_dir = output_dir .. "/different"
552 local page_count = popen_num(string.format("find %q -maxdepth 1 -name '*.html' | wc -l", sim_dir)) or 0
553 if page_count == 0 or num_poems == 0 then
554 -- First build: nothing to measure yet. Fall back to the NATURAL maximum (every
555 -- other poem could fill pages), i.e. effectively uncapped, and warn. A finite
556 -- value keeps %d logging and the math.min() cap well-defined; the next build
557 -- measures real page sizes and applies the storage cap.
558 local per_page = PAGINATION_CONFIG.poems_per_page
559 local natural_max = math.max(1, math.ceil(num_poems / (per_page > 0 and per_page or 1)))
560 utils.log_warn("Storage page cap: no pages in " .. sim_dir .. " to measure -- "
561 .. "generating UNCAPPED this build (natural max " .. natural_max
562 .. " pages/poem); re-run to apply the measured cap.")
563 return natural_max
564 end
565
566 local sim_bytes = dir_bytes(sim_dir)
567 local avg_page = sim_bytes / page_count
568 local per_page_level = avg_page * num_poems * 2
569 local fixed = math.max(0, dir_bytes(output_dir) - sim_bytes - dir_bytes(diff_dir))
570 local budget = STORAGE_CONFIG.limit_gb * 1e9 -- decimal GB; conservative vs GiB
571
572 local max_pages = math.floor((budget - fixed) / per_page_level)
573 if max_pages < 1 then max_pages = 1 end
574
575 utils.log_info(string.format(
576 "Storage page cap (measured): %d page(s)/poem -- budget %dGB, fixed output %.1fGB, "
577 .. "%.0fKB/page x %d poems x 2 sides", max_pages, STORAGE_CONFIG.limit_gb,
578 fixed / 1e9, avg_page / 1000, num_poems))
579 return max_pages
580end
581-- }}}
582
583-- {{{ local function calculate_page_count
584-- Calculates the total number of pages needed for a given poem count
585-- Returns: number of pages (always at least 1)
586local function calculate_page_count(total_poems)
587 local poems_per_page = PAGINATION_CONFIG.poems_per_page
588 return math.ceil(total_poems / poems_per_page)
589end
590-- }}}
591
592-- {{{ local function parse_pages_specification
593-- Parses the --pages flag value into a list of page numbers or special value
594-- Supports formats:
595-- nil or "default" → Use minimum_pages from config (usually {1})
596-- "all" → Generate all pages up to max_pages_per_poem limit
597-- "N" → Single page number, e.g., "1" → {1}, "5" → {5}
598-- "N-M" → Range of pages, e.g., "1-10" → {1,2,...,10}
599-- Returns: {pages = {1,2,3,...}, is_all = boolean}
600-- is_all flag indicates if we should generate all pages (respecting max_pages limit)
601local function parse_pages_specification(pages_spec, total_pages_possible)
602 -- Ensure pagination config is loaded
603 load_pagination_config()
604
605 -- Default: use minimum_pages from config
606 if not pages_spec or pages_spec == "" or pages_spec == "default" then
607 local pages = {}
608 for i = 1, PAGINATION_CONFIG.minimum_pages do
609 table.insert(pages, i)
610 end
611 return {pages = pages, is_all = false}
612 end
613
614 -- "all" means generate all pages up to max_pages_per_poem limit
615 if pages_spec == "all" then
616 return {pages = nil, is_all = true} -- nil means "generate all" in context
617 end
618
619 -- Single page number: "5" → {5}
620 local single_num = tonumber(pages_spec)
621 if single_num then
622 return {pages = {single_num}, is_all = false}
623 end
624
625 -- Range: "1-10" → {1,2,3,...,10}
626 local start_page, end_page = pages_spec:match("^(%d+)%-(%d+)$")
627 if start_page and end_page then
628 start_page = tonumber(start_page)
629 end_page = tonumber(end_page)
630
631 if start_page and end_page and start_page <= end_page then
632 local pages = {}
633 for i = start_page, end_page do
634 table.insert(pages, i)
635 end
636 return {pages = pages, is_all = false}
637 else
638 utils.log_error(string.format("Invalid page range: %s (start must be <= end)", pages_spec))
639 return {pages = {1}, is_all = false} -- Fallback to page 1
640 end
641 end
642
643 -- Invalid format - fallback to page 1
644 utils.log_error(string.format("Invalid --pages format: '%s'. Expected: 1, all, or 1-10", pages_spec))
645 return {pages = {1}, is_all = false}
646end
647-- }}}
648
649-- {{{ local function get_poems_for_page
650-- Extracts poems for a specific page from a sorted list
651-- page_num is 1-indexed
652-- Returns: table of poem entries for that page
653local function get_poems_for_page(sorted_poems, page_num)
654 local poems_per_page = PAGINATION_CONFIG.poems_per_page
655 local start_idx = ((page_num - 1) * poems_per_page) + 1
656 local end_idx = math.min(start_idx + poems_per_page - 1, #sorted_poems)
657
658 local page_poems = {}
659 for i = start_idx, end_idx do
660 if sorted_poems[i] then
661 table.insert(page_poems, sorted_poems[i])
662 end
663 end
664
665 return page_poems
666end
667-- }}}
668
669-- {{{ local function get_unique_poem_filename_id
670-- Generates a unique identifier for poem filenames using category prefix
671-- Solves cross-category ID collisions: fediverse/0002.txt and messages/0002.txt
672-- both have id=2 but become "fediverse-0002" and "messages-0002". See Issue 8-019.
673-- poem: poem object with id and category fields
674-- Returns: unique filename identifier like "fediverse-0002" or "messages-0767"
675local function get_unique_poem_filename_id(poem)
676 local category = poem.category or "unknown"
677 local id = poem.id or 0
678 return string.format("%s-%04d", category, id)
679end
680-- }}}
681
682-- {{{ local function get_poem_anchor_id
683-- Generates HTML anchor ID for linking to poems in chronological.html
684-- Issue 8-030: Add chronological anchor links
685-- Issue 16-006: Changed to use poem_index for simpler, machine-readable format
686-- Old format: "poem-fediverse-0042" (leaked category info)
687-- New format: "poem-4625" (just the unique poem_index)
688-- poem: poem object with poem_index field
689-- Returns: anchor ID like "poem-4625"
690local function get_poem_anchor_id(poem)
691 local poem_index = poem.poem_index or 0
692 return string.format("poem-%d", poem_index)
693end
694-- }}}
695
696-- {{{ local function format_page_number
697-- Formats a page number with zero-padding
698-- Returns: padded string like "01", "02", etc.
699local function format_page_number(page_num)
700 local padding = PAGINATION_CONFIG.page_number_padding
701 return string.format("%0" .. padding .. "d", page_num)
702end
703-- }}}
704
705-- {{{ local function generate_page_filename
706-- Generates the filename for a paginated page
707-- poem_id: the starting poem ID (for similarity/diversity pages)
708-- page_num: 1-indexed page number
709-- page_type: "similar" or "different"
710-- Returns: filename like "similar/0068-01.html"
711local function generate_page_filename(poem_id, page_num, page_type)
712 local padded_id = string.format("%04d", poem_id)
713 local padded_page = format_page_number(page_num)
714 return string.format("%s/%s-%s.html", page_type, padded_id, padded_page)
715end
716-- }}}
717
718-- {{{ local function generate_prev_next_navigation
719-- Generates prev/next navigation links for paginated pages
720-- current_page: 1-indexed current page
721-- total_pages: total number of pages (may be capped by max_pages_per_poem)
722-- poem_id: starting poem ID (nil for chronological)
723-- page_type: "similar", "different", or "chronological"
724-- total_corpus: optional - total poems in corpus (for storage context display)
725-- Returns: HTML string with navigation
726-- Updated for Issue 8-020: Shows storage constraint message on last page
727local function generate_prev_next_navigation(current_page, total_pages, poem_id, page_type, total_corpus)
728 local nav_parts = {}
729
730 -- Calculate poem range for this page
731 local poems_per_page = PAGINATION_CONFIG.poems_per_page
732 local max_pages = PAGINATION_CONFIG.max_pages_per_poem
733 local start_poem = ((current_page - 1) * poems_per_page) + 1
734 local end_poem = math.min(current_page * poems_per_page, total_pages * poems_per_page)
735
736 -- Check if this is a storage-constrained last page
737 local is_storage_limited = (total_pages == max_pages) and (total_corpus and total_corpus > end_poem)
738 local poems_shown = end_poem
739 local poems_omitted = total_corpus and (total_corpus - poems_shown) or 0
740
741 -- Header line with page info
742 table.insert(nav_parts, "════════════════════════════════════════════════════════════════════════════════")
743
744 if page_type == "chronological" then
745 table.insert(nav_parts, string.format(" Page %d of %d │ Poems %d-%d",
746 current_page, total_pages, start_poem, end_poem))
747 else
748 local padded_id = string.format("%04d", poem_id)
749 if is_storage_limited then
750 -- Show storage context on capped pages (Issue 8-020)
751 table.insert(nav_parts, string.format(" %s to Poem %s │ Page %d of %d │ Showing top %d poems",
752 page_type == "similar" and "Similar" or "Different",
753 padded_id, current_page, total_pages, poems_shown))
754 else
755 table.insert(nav_parts, string.format(" %s to Poem %s │ Page %d of %d │ Poems %d-%d",
756 page_type == "similar" and "Similar" or "Different",
757 padded_id, current_page, total_pages, start_poem, end_poem))
758 end
759 end
760
761 table.insert(nav_parts, "════════════════════════════════════════════════════════════════════════════════")
762
763 -- Storage constraint notice on last page (Issue 8-020)
764 if is_storage_limited and current_page == total_pages and poems_omitted > 0 then
765 table.insert(nav_parts, string.format(" (%d additional poems omitted for storage constraints)",
766 poems_omitted))
767 end
768
769 table.insert(nav_parts, "")
770
771 -- Navigation links
772 local nav_line = ""
773
774 -- Previous link (left aligned)
775 if current_page > 1 then
776 local prev_file
777 if page_type == "chronological" then
778 -- Issue 8-039 Fix: Chronological pages now in subdirectory, use relative paths
779 prev_file = string.format("%s.html", format_page_number(current_page - 1))
780 else
781 prev_file = string.format("%s-%s.html", string.format("%04d", poem_id), format_page_number(current_page - 1))
782 end
783 nav_line = string.format("[<a href=\"%s\">◀ Previous Page</a>]", prev_file)
784 else
785 nav_line = "[◀ Previous Page]" -- Disabled
786 end
787
788 -- Calculate padding to push next link to right side
789 local padding = 80 - #nav_line - 16 -- 16 chars for next link
790 if padding < 0 then padding = 0 end
791 nav_line = nav_line .. string.rep(" ", padding)
792
793 -- Next link (right aligned)
794 if current_page < total_pages then
795 local next_file
796 if page_type == "chronological" then
797 -- Issue 8-039 Fix: Chronological pages now in subdirectory, use relative paths
798 next_file = string.format("%s.html", format_page_number(current_page + 1))
799 else
800 next_file = string.format("%s-%s.html", string.format("%04d", poem_id), format_page_number(current_page + 1))
801 end
802 nav_line = nav_line .. string.format("[<a href=\"%s\">Next Page ▶</a>]", next_file)
803 else
804 nav_line = nav_line .. "[Next Page ▶]" -- Disabled
805 end
806
807 table.insert(nav_parts, nav_line)
808 table.insert(nav_parts, "────────────────────────────────────────────────────────────────────────────────")
809
810 return table.concat(nav_parts, "\n")
811end
812-- }}}
813
814-- {{{ function load_poem_colors
815-- Note: Only loads and logs once per session (idempotent)
816local cached_poem_colors = nil
817
818local function load_poem_colors()
819 -- Skip if already loaded (idempotent)
820 if cached_poem_colors then
821 return cached_poem_colors
822 end
823
824 local poem_colors_file = utils.embeddings_dir() .. "/poem_colors.json"
825 local poem_colors_data = utils.read_json_file(poem_colors_file)
826
827 if poem_colors_data and poem_colors_data.poem_colors then
828 -- Count actual entries dynamically (stored total_poems may be stale)
829 cached_poem_colors = poem_colors_data.poem_colors
830 return cached_poem_colors
831 else
832 utils.log_warn("Could not load poem colors, using mock colors")
833 cached_poem_colors = MOCK_POEM_COLORS
834 return cached_poem_colors
835 end
836end
837-- }}}
838
839-- {{{ function get_file_creation_timestamp
840local function get_file_creation_timestamp(file_path)
841 -- Use bash stat command to get file modification time (best approximation)
842 local cmd = string.format("stat -c %%Y '%s' 2>/dev/null", file_path)
843 local handle = io.popen(cmd)
844
845 if handle then
846 local result = handle:read("*a")
847 handle:close()
848
849 if result and result:match("^%d+") then
850 return tonumber(result:match("^%d+"))
851 end
852 end
853
854 return nil
855end
856-- }}}
857
858-- {{{ function extract_post_date_from_poem
859local function extract_post_date_from_poem(poem_data)
860 -- First, try to use the creation_date metadata field (if available)
861 local creation_date = poem_data.creation_date or (poem_data.metadata and poem_data.metadata.creation_date)
862 if creation_date then
863 -- Parse ISO 8601 format: "2023-04-20T05:22:03" or "2023-04-20T05:22:03Z"
864 local year, month, day, hour, min, sec = creation_date:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
865 if year and month and day then
866 local parsed_time = os.time({
867 year = tonumber(year),
868 month = tonumber(month),
869 day = tonumber(day),
870 hour = tonumber(hour) or 0,
871 min = tonumber(min) or 0,
872 sec = tonumber(sec) or 0
873 })
874 if parsed_time then return parsed_time end
875 end
876
877 -- Fallback: try to extract just date part
878 year, month, day = creation_date:match("(%d+)-(%d+)-(%d+)")
879 if year and month and day then
880 local parsed_time = os.time({
881 year = tonumber(year),
882 month = tonumber(month),
883 day = tonumber(day),
884 hour = 0, min = 0, sec = 0
885 })
886 if parsed_time then return parsed_time end
887 end
888 end
889
890 -- Fallback: Look for date patterns in poem content (legacy logic)
891 local content = poem_data.content or ""
892
893 -- First, try to extract YYYY-MM-DD from the very beginning (processing artifact dates)
894 local year, month, day = content:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)")
895 if year and month and day then
896 return os.time({year=tonumber(year), month=tonumber(month), day=tonumber(day)})
897 end
898
899 -- Try to extract date from first line (other patterns)
900 local date_line = content:match("^([^\n]+)")
901 if date_line then
902 -- MM/DD/YYYY format
903 local month, day, year = date_line:match("(%d%d)/(%d%d)/(%d%d%d%d)")
904 if month and day and year then
905 return os.time({year=tonumber(year), month=tonumber(month), day=tonumber(day)})
906 end
907
908 -- Month DD, YYYY format (like "april 16th 2023")
909 local month_name, day_num, year_num = date_line:match("(%w+)%s+(%d+)%w*%s+(%d%d%d%d)")
910 if month_name and day_num and year_num then
911 local month_map = {
912 january=1, february=2, march=3, april=4, may=5, june=6,
913 july=7, august=8, september=9, october=10, november=11, december=12
914 }
915 local month_num = month_map[month_name:lower()]
916 if month_num then
917 return os.time({year=tonumber(year_num), month=month_num, day=tonumber(day_num)})
918 end
919 end
920 end
921
922 -- Fallback to file creation time if available
923 if poem_data.filepath then
924 local timestamp = get_file_creation_timestamp(poem_data.filepath)
925 if timestamp then
926 return timestamp
927 end
928 end
929
930 -- Final fallback to poem ID as timestamp approximation
931 return poem_data.id or 0
932end
933-- }}}
934
935-- {{{ function sort_poems_chronologically_by_dates
936local function sort_poems_chronologically_by_dates(poems_data)
937 local sorted_poems = {}
938
939 -- Extract all poems with temporal sorting data
940 for i, poem in ipairs(poems_data.poems) do
941 if poem.id then
942 local post_timestamp = extract_post_date_from_poem(poem)
943 table.insert(sorted_poems, {
944 poem = poem,
945 timestamp = post_timestamp,
946 sort_key = post_timestamp,
947 original_index = i
948 })
949 end
950 end
951
952 -- Sort by actual temporal order
953 table.sort(sorted_poems, function(a, b)
954 -- If timestamps are equal, use original index as tiebreaker
955 if a.sort_key == b.sort_key then
956 return a.original_index < b.original_index
957 end
958 return a.sort_key < b.sort_key
959 end)
960
961 return sorted_poems
962end
963-- }}}
964
965-- {{{ function calculate_chronological_progress
966local function calculate_chronological_progress(poem_id, total_poems)
967 -- Calculate percentage through chronological corpus
968 local progress_percentage = (poem_id / total_poems) * 100
969
970 return {
971 poem_id = poem_id,
972 total_poems = total_poems,
973 percentage = progress_percentage,
974 position = poem_id,
975 quartile = math.ceil(progress_percentage / 25)
976 }
977end
978-- }}}
979
980-- {{{ function compute_chronological_mapping
981-- Computes poem_index → {position, page_number, total_poems, total_pages, timeline_progress}
982-- Used by parallel workers to generate correct chronological links and progress bars
983-- Issue 8-045: Added timeline_progress for time-based progress bar calculation
984local function compute_chronological_mapping(poems_data, chrono_poems_per_page)
985 -- Sort chronologically (same as generate_chronological_index_with_navigation)
986 local sorted_poems = sort_poems_chronologically_by_dates(poems_data)
987 local total_poems = #sorted_poems
988 local total_pages = chrono_poems_per_page and math.ceil(total_poems / chrono_poems_per_page) or 1
989
990 -- Issue 8-045: Calculate timeline bounds for time-based progress
991 -- sorted_poems[i].timestamp contains Unix timestamp from extract_post_date_from_poem()
992 local first_timestamp = sorted_poems[1] and sorted_poems[1].timestamp or 0
993 local last_timestamp = sorted_poems[total_poems] and sorted_poems[total_poems].timestamp or 0
994 local timeline_span = last_timestamp - first_timestamp
995 -- Avoid division by zero if all poems have same timestamp
996 if timeline_span <= 0 then timeline_span = 1 end
997
998 -- Build mapping
999 local mapping = {}
1000 for position, poem_info in ipairs(sorted_poems) do
1001 local poem = poem_info.poem
1002 local poem_index = poem.poem_index
1003 if poem_index then
1004 local page_number = chrono_poems_per_page and math.ceil(position / chrono_poems_per_page) or 1
1005 -- Issue 8-045: Calculate timeline progress as percentage of time elapsed
1006 local poem_timestamp = poem_info.timestamp or first_timestamp
1007 local timeline_progress = ((poem_timestamp - first_timestamp) / timeline_span) * 100
1008 mapping[poem_index] = {
1009 position = position,
1010 page_number = page_number,
1011 total_poems = total_poems,
1012 total_pages = total_pages,
1013 timeline_progress = timeline_progress -- Issue 8-045: time-based progress
1014 }
1015 end
1016 end
1017
1018 return mapping
1019end
1020-- }}}
1021
1022-- Exported so the word-cloud pages reuse this EXACT chronological mapping (same
1023-- timestamp sort + original-index tiebreaker + page size). A divergent inline
1024-- copy in generate-word-pages sorted by the raw creation_date string with no
1025-- tiebreaker and its own page-size default, so it computed different page
1026-- numbers -> "chronological" links pointed at pages the poem wasn't on and never
1027-- scrolled. One mapping, one answer.
1028M.compute_chronological_mapping = compute_chronological_mapping
1029-- {{{ function M.default_chrono_per_page()
1030-- The chronological page size, from config. There is no compiled-in fallback on
1031-- purpose: a runtime --chrono-per-page override is the OTHER legitimate source
1032-- (callers prefer that and use this only when no override was given), and if the
1033-- config key is somehow missing that is a broken config we want to hear about,
1034-- not paper over with a silent default that would mis-paginate every poem link.
1035function M.default_chrono_per_page()
1036 -- Pull config.lua's pagination overrides into PAGINATION_CONFIG first, so the
1037 -- default reflects the CONFIG FILE (where --chrono-per-page's default lives),
1038 -- not the bare source-table placeholder. Idempotent; safe to call anywhere.
1039 load_pagination_config()
1040 local value = PAGINATION_CONFIG.chronological_poems_per_page
1041 if not value then
1042 error("config is missing chronological_poems_per_page; chronological "
1043 .. "pagination size is required (pass --chrono-per-page or set it "
1044 .. "in the pagination config)")
1045 end
1046 return value
1047end
1048-- }}}
1049
1050-- {{{ function generate_progress_dashes
1051local function generate_progress_dashes(progress_info, color_name, is_golden, position, has_corner_boxes)
1052 -- For golden poems: 82 chars interior (+ 2 corners = 84 total)
1053 -- For regular poems: 83 chars total (positions 0-82)
1054 -- Golden poems have corner characters (╔/┐ or ╚/┘) that add 2 to the width,
1055 -- so interior needs to be 1 less to maintain 84-char total alignment
1056 local total_chars = is_golden and 82 or 83
1057 local progress_chars = math.floor((progress_info.percentage / 100) * total_chars)
1058 local remaining_chars = total_chars - progress_chars
1059
1060 -- Get color information
1061 local hex_color = COLOR_CONFIG[color_name] or COLOR_CONFIG["gray"]
1062
1063 -- For golden bottom borders with corner boxes, we need to insert junction characters
1064 -- Issue 8-055: Fixed junction positions to align ╧/┴ under ┐/┌ corners
1065 -- Junction positions in the 82-char interior (0-indexed):
1066 -- - Position 10: under "similar" box ┐ (same as regular poems)
1067 -- - Position 71: under "different" box ┌ (regular + 1 due to wider golden poem)
1068 local LEFT_JUNCTION_POS = 10 -- Same as regular: left box ┐ at position 10
1069 local RIGHT_JUNCTION_POS = 71 -- Regular + 1: right box ┌ at position 71 (golden is 1 char wider)
1070
1071 -- Junction positions for regular poems (different from golden due to no outer walls)
1072 -- Regular corner boxes: ┌─────────┐ (11 chars) + 59 spaces + ┌───────────┐ (13 chars) = 83 chars
1073 -- Inner walls at positions 10 and 70 (0-indexed)
1074 local REGULAR_LEFT_JUNCTION_POS = 10
1075 local REGULAR_RIGHT_JUNCTION_POS = 70
1076
1077 local visual_output
1078 if is_golden and position == "bottom" and has_corner_boxes then
1079 -- Build progress bar with junction characters inserted
1080 -- We need to construct the bar character by character to insert junctions at the right spots
1081
1082 -- Determine which junction character to use at each position
1083 -- ╧ (U+2567) - up single and horizontal double (connects to ═) - COLORED
1084 -- ┴ (U+2534) - up and horizontal single (connects to ─) - UNCOLORED
1085 local left_in_progress = LEFT_JUNCTION_POS < progress_chars
1086 local right_in_progress = RIGHT_JUNCTION_POS < progress_chars
1087
1088 -- Build colored junctions (╧ when in progress section)
1089 local left_junction
1090 if left_in_progress then
1091 left_junction = string.format('<font color="%s"><b>╧</b></font>', hex_color)
1092 else
1093 left_junction = "┴"
1094 end
1095
1096 local right_junction
1097 if right_in_progress then
1098 right_junction = string.format('<font color="%s"><b>╧</b></font>', hex_color)
1099 else
1100 right_junction = "┴"
1101 end
1102
1103 -- Build the progress section (colored ═) and remaining section (─)
1104 -- We need to split around the junction positions
1105 local segments = {}
1106 local current_pos = 0
1107
1108 -- Helper to add a segment with proper coloring
1109 local function add_segment(start_pos, end_pos)
1110 if end_pos <= start_pos then return end
1111 local seg_len = end_pos - start_pos
1112
1113 -- Determine how much of this segment is progress vs remaining
1114 local progress_in_seg = math.max(0, math.min(seg_len, progress_chars - start_pos))
1115 local remaining_in_seg = seg_len - progress_in_seg
1116
1117 if progress_in_seg > 0 then
1118 table.insert(segments, string.format('<font color="%s"><b>%s</b></font>',
1119 hex_color, string.rep("═", progress_in_seg)))
1120 end
1121 if remaining_in_seg > 0 then
1122 table.insert(segments, string.rep("─", remaining_in_seg))
1123 end
1124 end
1125
1126 -- Bugfix: this copy started at 0 and ended at total_chars, landing the
1127 -- left junction one column too far right (col 11) and the right one a
1128 -- dash short -- so the bottom bar did not line up under the nav-box
1129 -- corners. Start at 1 and end at total_chars+1 to match poem-bars (the
1130 -- word pages, which were correct): 9 dashes before the left junction so
1131 -- it sits at column 10, 11 after the right junction. Width is unchanged.
1132 -- Segment 1: corner ╚ is column 0, so the first dash runs 1..left junction
1133 add_segment(1, LEFT_JUNCTION_POS)
1134 -- Insert left junction (colored if ╧, plain if ┴)
1135 table.insert(segments, left_junction)
1136
1137 -- Segment 2: from left junction + 1 to right junction (exclusive)
1138 add_segment(LEFT_JUNCTION_POS + 1, RIGHT_JUNCTION_POS)
1139 -- Insert right junction (colored if ╧, plain if ┴)
1140 table.insert(segments, right_junction)
1141
1142 -- Segment 3: from right junction + 1 to the far corner (exclusive of ┘)
1143 add_segment(RIGHT_JUNCTION_POS + 1, total_chars + 1)
1144
1145 local interior = table.concat(segments, "")
1146 -- Color the ╚ corner to match the progress bar
1147 local colored_corner = string.format('<font color="%s"><b>╚</b></font>', hex_color)
1148 visual_output = colored_corner .. interior .. "┘"
1149
1150 elseif not is_golden and position == "bottom" and has_corner_boxes then
1151 -- Regular poem bottom border with corner characters and junctions connecting to corner boxes
1152 -- Structure: ╘ (pos 0) + progress bar + ┴/╧ (pos 10) + progress bar + ┴/╧ (pos 69) + progress bar + ┘ (pos 81)
1153 -- ╘ (U+2558) - up single and right double - closes left box, connects to ═ progress
1154 -- ┘ (U+2518) - light up and left - closes right box, connects to ─ remaining
1155
1156 local left_in_progress = REGULAR_LEFT_JUNCTION_POS < progress_chars
1157 local right_in_progress = REGULAR_RIGHT_JUNCTION_POS < progress_chars
1158
1159 -- Build colored junctions (╧ when in progress section, ┴ otherwise)
1160 local left_junction
1161 if left_in_progress then
1162 left_junction = string.format('<font color="%s"><b>╧</b></font>', hex_color)
1163 else
1164 left_junction = "┴"
1165 end
1166
1167 local right_junction
1168 if right_in_progress then
1169 right_junction = string.format('<font color="%s"><b>╧</b></font>', hex_color)
1170 else
1171 right_junction = "┴"
1172 end
1173
1174 -- Left corner ╘ - colored if progress > 0 (position 0 is always in progress section if any progress)
1175 local left_corner
1176 if progress_chars > 0 then
1177 left_corner = string.format('<font color="%s"><b>╘</b></font>', hex_color)
1178 else
1179 left_corner = "╘"
1180 end
1181
1182 -- Right corner ┘ - always uncolored (position 82 is almost never in progress section)
1183 local right_corner = "┘"
1184
1185 -- Build the progress bar with junctions
1186 local segments = {}
1187
1188 -- Helper to add a segment with proper coloring
1189 -- Note: positions are now 1-80 since 0 and 81 are corner characters
1190 local function add_segment(start_pos, end_pos)
1191 if end_pos <= start_pos then return end
1192 local seg_len = end_pos - start_pos
1193
1194 local progress_in_seg = math.max(0, math.min(seg_len, progress_chars - start_pos))
1195 local remaining_in_seg = seg_len - progress_in_seg
1196
1197 if progress_in_seg > 0 then
1198 table.insert(segments, string.format('<font color="%s"><b>%s</b></font>',
1199 hex_color, string.rep("═", progress_in_seg)))
1200 end
1201 if remaining_in_seg > 0 then
1202 table.insert(segments, string.rep("─", remaining_in_seg))
1203 end
1204 end
1205
1206 -- Start with left corner
1207 table.insert(segments, left_corner)
1208
1209 -- Segment 1: from 1 to left junction (exclusive) - 9 chars
1210 add_segment(1, REGULAR_LEFT_JUNCTION_POS)
1211 table.insert(segments, left_junction)
1212
1213 -- Segment 2: from left junction + 1 to right junction (exclusive) - 59 chars
1214 add_segment(REGULAR_LEFT_JUNCTION_POS + 1, REGULAR_RIGHT_JUNCTION_POS)
1215 table.insert(segments, right_junction)
1216
1217 -- Segment 3: from right junction + 1 to end - 1 (exclusive of right corner) - 11 chars
1218 add_segment(REGULAR_RIGHT_JUNCTION_POS + 1, total_chars - 1)
1219
1220 -- End with right corner
1221 table.insert(segments, right_corner)
1222
1223 -- No padding needed - content has 1-space indent for alignment
1224 visual_output = table.concat(segments, "")
1225
1226 elseif is_golden then
1227 -- Golden poem top border or bottom without corner boxes
1228 -- Create progress visualization using equals/dash distinction
1229 local progress_section = string.rep("═", progress_chars)
1230 local remaining_section = string.rep("─", remaining_chars)
1231
1232 local colored_progress = string.format(
1233 '<font color="%s"><b>%s</b></font>%s',
1234 hex_color, progress_section, remaining_section
1235 )
1236
1237 -- Color the left corners to match the progress bar
1238 local colored_top_corner = string.format('<font color="%s"><b>╔</b></font>', hex_color)
1239 local colored_bottom_corner = string.format('<font color="%s"><b>╚</b></font>', hex_color)
1240
1241 if position == "top" then
1242 visual_output = colored_top_corner .. colored_progress .. "┐"
1243 elseif position == "bottom" then
1244 visual_output = colored_bottom_corner .. colored_progress .. "┘"
1245 else
1246 visual_output = colored_top_corner .. colored_progress .. "┐"
1247 end
1248 else
1249 -- Regular poems: no padding needed - content has 1-space indent for alignment
1250 local progress_section = string.rep("═", progress_chars)
1251 local remaining_section = string.rep("─", remaining_chars)
1252
1253 local colored_progress = string.format(
1254 '<font color="%s"><b>%s</b></font>%s',
1255 hex_color, progress_section, remaining_section
1256 )
1257 visual_output = colored_progress
1258 end
1259
1260 -- Screen reader accessible version - brief format for frequent use
1261 local screen_reader_text
1262 if is_golden then
1263 screen_reader_text = string.format(
1264 'aria-label="golden poem border. %s."',
1265 color_name
1266 )
1267 else
1268 screen_reader_text = string.format(
1269 'aria-label="eighty dashes. %s."',
1270 color_name
1271 )
1272 end
1273
1274 return {
1275 visual = visual_output,
1276 accessibility = screen_reader_text,
1277 raw_progress = progress_chars,
1278 raw_remaining = remaining_chars,
1279 color = color_name,
1280 percentage = progress_info.percentage,
1281 is_golden = is_golden or false
1282 }
1283end
1284-- }}}
1285
1286-- {{{ function wrap_single_line_80_chars
1287local function wrap_single_line_80_chars(line)
1288 -- Wrap a single line to 80 characters, preserving words
1289 if #line <= 80 then
1290 return line
1291 end
1292
1293 local result_lines = {}
1294 local words = {}
1295
1296 for word in line:gmatch("%S+") do
1297 table.insert(words, word)
1298 end
1299
1300 local current_line = ""
1301 for _, word in ipairs(words) do
1302 if #current_line == 0 then
1303 current_line = word
1304 elseif #current_line + 1 + #word <= 80 then
1305 current_line = current_line .. " " .. word
1306 else
1307 table.insert(result_lines, current_line)
1308 current_line = word
1309 end
1310 end
1311
1312 if #current_line > 0 then
1313 table.insert(result_lines, current_line)
1314 end
1315
1316 return table.concat(result_lines, "\n")
1317end
1318-- }}}
1319
1320-- {{{ function strip_html_tags
1321local function strip_html_tags(content)
1322 -- Strip all HTML tags and decode HTML entities for TXT export
1323 -- Images should be converted with render_attachment_images_txt() separately
1324 local result = content
1325
1326 -- Strip HTML tags
1327 result = result:gsub("<[^>]+>", "")
1328
1329 -- Decode common HTML entities
1330 result = result:gsub("&amp;", "&")
1331 result = result:gsub("&lt;", "<")
1332 result = result:gsub("&gt;", ">")
1333 result = result:gsub("&quot;", '"')
1334 result = result:gsub("&#39;", "'")
1335 result = result:gsub("&nbsp;", " ")
1336 result = result:gsub("&#(%d+);", function(n)
1337 return string.char(tonumber(n))
1338 end)
1339
1340 -- Normalize multiple consecutive spaces/newlines
1341 result = result:gsub("[ \t]+", " ")
1342 result = result:gsub("\n[ \t]+", "\n")
1343 result = result:gsub("[ \t]+\n", "\n")
1344 result = result:gsub("\n\n\n+", "\n\n")
1345
1346 return result
1347end
1348-- }}}
1349
1350-- {{{ function wrap_text_80_chars
1351local function wrap_text_80_chars(text)
1352 -- Wrap text to 80 chars while preserving existing newlines (paragraph breaks)
1353 local input_lines = {}
1354 for line in (text .. "\n"):gmatch("(.-)\n") do
1355 table.insert(input_lines, line)
1356 end
1357
1358 local output_lines = {}
1359 for _, line in ipairs(input_lines) do
1360 if #line == 0 then
1361 -- Preserve empty lines (paragraph breaks)
1362 table.insert(output_lines, "")
1363 else
1364 -- Wrap long lines
1365 local wrapped = wrap_single_line_80_chars(line)
1366 for wrapped_line in (wrapped .. "\n"):gmatch("(.-)\n") do
1367 table.insert(output_lines, wrapped_line)
1368 end
1369 end
1370 end
1371
1372 return table.concat(output_lines, "\n")
1373end
1374-- }}}
1375
1376-- {{{ function M.generate_similarity_ranked_list
1377-- Cache-only similarity ranking lookup (no on-the-fly sorting)
1378-- Requires pre-computed similarity rankings cache from: ./run.sh --generate-similarity
1379-- Parameter similarity_data is kept for API compatibility but not used when cache is available
1380function M.generate_similarity_ranked_list(starting_poem_id, poems_data, similarity_data)
1381 -- Verify cache is loaded
1382 if not SIMILARITY_RANKINGS_CACHE then
1383 error("Similarity rankings cache not loaded! Run: ./run.sh --generate-similarity")
1384 end
1385
1386 if not SIMILARITY_RANKINGS_CACHE.rankings then
1387 error("Similarity rankings cache has invalid format (missing rankings table)")
1388 end
1389
1390 -- Look up pre-sorted ranking for this poem
1391 local cached_ranking = SIMILARITY_RANKINGS_CACHE.rankings[tostring(starting_poem_id)]
1392 if not cached_ranking then
1393 error(string.format("Similarity ranking not found for poem %s in cache.", starting_poem_id))
1394 end
1395
1396 -- Build poem index lookup for fast access
1397 local poem_by_index = {}
1398 for i, poem in ipairs(poems_data.poems) do
1399 if poem.poem_index then
1400 poem_by_index[poem.poem_index] = poem
1401 end
1402 end
1403
1404 -- Initialize ranked list with starting poem
1405 local ranked_poems = {}
1406 local starting_poem = poems_data.poems[starting_poem_id]
1407 table.insert(ranked_poems, {
1408 id = starting_poem_id,
1409 poem = starting_poem,
1410 similarity = 1.0, -- Perfect similarity to self
1411 rank = 1
1412 })
1413
1414 -- Add poems in pre-sorted order from cache
1415 -- Cache contains poem indices already sorted by similarity (descending)
1416 local rank = 2
1417 for _, target_poem_index in ipairs(cached_ranking) do
1418 local poem = poem_by_index[target_poem_index]
1419 if poem then
1420 table.insert(ranked_poems, {
1421 id = poem.id,
1422 poem = poem,
1423 similarity = nil, -- Not needed for display, saves memory
1424 rank = rank
1425 })
1426 rank = rank + 1
1427 end
1428 end
1429
1430 return ranked_poems
1431end
1432-- }}}
1433
1434-- {{{ function M.generate_maximum_diversity_sequence
1435-- Cache-only diversity sequence lookup (no on-the-fly computation)
1436-- Requires pre-computed GPU diversity cache from: ./run.sh --generate-diversity
1437function M.generate_maximum_diversity_sequence(starting_poem_id, poems_data, embeddings_data)
1438 -- Verify cache is loaded
1439 if not DIVERSITY_CACHE then
1440 error("Diversity cache not loaded! Run: ./run.sh --generate-diversity")
1441 end
1442
1443 if not DIVERSITY_CACHE.sequences then
1444 error("Diversity cache has invalid format (missing sequences table)")
1445 end
1446
1447 -- Look up pre-computed sequence
1448 local cached_sequence = DIVERSITY_CACHE.sequences[tostring(starting_poem_id)]
1449 if not cached_sequence then
1450 error(string.format("Diversity sequence not found for poem %d in cache. Cache may be corrupted or incomplete.", starting_poem_id))
1451 end
1452
1453 -- Convert cached poem_index values to full poem objects
1454 -- Note: The diversity cache stores poem_index (globally unique), NOT poem.id (per-category)
1455 local diversity_sequence = {}
1456 local poem_lookup = {}
1457
1458 -- Build lookup table keyed by poem_index (NOT poem.id which is per-category)
1459 for i, poem in ipairs(poems_data.poems) do
1460 if poem.poem_index then
1461 poem_lookup[poem.poem_index] = poem
1462 end
1463 end
1464
1465 -- Convert cached sequence (contains poem_index values) to format expected by HTML generator
1466 -- Issue 10-025: Skip anchor poem (GPU cache stores source poem as first entry)
1467 for step, poem_index in ipairs(cached_sequence) do
1468 if poem_index ~= starting_poem_id then
1469 local poem = poem_lookup[poem_index]
1470 if poem then
1471 table.insert(diversity_sequence, {
1472 id = poem_index, -- Store poem_index for consistency
1473 poem = poem,
1474 step = step
1475 })
1476 end
1477 end
1478 end
1479
1480 return diversity_sequence
1481end
1482-- }}}
1483
1484-- {{{ function render_attachment_images
1485-- Issue 8-049: Renamed conceptually to render all media types (images, audio, video)
1486-- Function name kept for backwards compatibility with existing call sites
1487local function render_attachment_images(attachments)
1488 -- Render HTML for poem attachments (images, audio, video)
1489 -- Returns empty string if no attachments or no renderable attachments
1490 -- Media output format designed for 80-char width aesthetic
1491 --
1492 -- ATTACHMENT STRUCTURE (from ActivityPub extraction):
1493 -- {
1494 -- media_type = "image/png" or "audio/mpeg" or "video/mp4",
1495 -- url = "https://server.com/media/files/123/456/original/abc.png",
1496 -- relative_path = "files/123/456/original/abc.png",
1497 -- alt_text = "User description" or nil,
1498 -- width = 1920, -- images/video only
1499 -- height = 1080 -- images/video only
1500 -- }
1501
1502 if not attachments or #attachments == 0 then
1503 return ""
1504 end
1505
1506 local media_html = {}
1507 -- "up to the site root" -- these attachments render on poem pages, which sit
1508 -- one level below output/ (output/similar/, output/different/, ...), so a
1509 -- "../" prefix reaches the root. Document-relative: resolves the same opened
1510 -- locally from any folder or served on the site, so no path conversion step.
1511 local base_path = ".."
1512
1513 for _, attachment in ipairs(attachments) do
1514 local media_type = attachment.media_type or ""
1515 -- Issue 8-048: media lives at output/media/<source>/<subpath> (see
1516 -- flatten_media_files); media_href keeps art's source+subdir structure so
1517 -- same-named pieces don't collide. "../media/" reaches it from a poem page.
1518 local relative_path = attachment.relative_path or ""
1519 -- media_href namespaces art by source+subdir (collision-safe) and
1520 -- url-encodes; Mastodon hashes collapse to the bare name. Matches where
1521 -- flatten_media_files placed the file.
1522 local media_src = base_path .. "/media/" .. media_href(relative_path)
1523
1524 if media_type:match("^image/") then
1525 -- Use alt text if available, otherwise generate generic description
1526 -- Issue 9-012: ActivityPub uses 'description' field for alt-text
1527 local alt_text = attachment.description or attachment.alt_text or "Image attachment"
1528 -- Issue 8-053: Normalize newlines to spaces for clean HTML attributes
1529 alt_text = alt_text:gsub("\n", " "):gsub("\r", "")
1530 -- Escape quotes in alt text for HTML attribute
1531 alt_text = alt_text:gsub('"', '&quot;')
1532
1533 -- Build image tag with lazy loading for performance
1534 -- Issue 8-005 Fix: Add max-width to prevent viewport overflow
1535 -- display:block prevents multiple images from appearing side-by-side
1536 -- max-width:min(100%,800px) caps at content width (~80 chars) while being responsive
1537 -- width/height hints help browser reserve space before load (aspect ratio preserved)
1538 -- Issue 8-053: title attribute provides mouse-over tooltip for sighted users
1539 local img_tag
1540 if attachment.width and attachment.height then
1541 img_tag = string.format(
1542 ' <img src="%s" alt="%s" title="%s" loading="lazy" width="%d" height="%d" style="display:block; max-width:min(100%%,800px); height:auto">',
1543 media_src, alt_text, alt_text, attachment.width, attachment.height
1544 )
1545 else
1546 img_tag = string.format(
1547 ' <img src="%s" alt="%s" title="%s" loading="lazy" style="display:block; max-width:min(100%%,800px); height:auto">',
1548 media_src, alt_text, alt_text
1549 )
1550 end
1551 table.insert(media_html, img_tag)
1552
1553 elseif media_type:match("^audio/") then
1554 -- Issue 8-049: Audio playback support
1555 -- controls: Shows play/pause, volume, seek bar
1556 -- preload="metadata": Only loads duration/metadata initially for performance
1557 local audio_tag = string.format(
1558 ' <audio controls preload="metadata" style="display:block; max-width:100%%">\n' ..
1559 ' <source src="%s" type="%s">\n' ..
1560 ' Your browser does not support the audio element.\n' ..
1561 ' </audio>',
1562 media_src, media_type
1563 )
1564 table.insert(media_html, audio_tag)
1565
1566 elseif media_type:match("^video/") then
1567 -- Issue 8-049: Video playback support
1568 -- controls: Shows play/pause, volume, seek bar, fullscreen
1569 -- preload="metadata": Only loads poster frame initially for performance
1570 -- max-width caps at content width while being responsive
1571 local video_tag
1572 if attachment.width and attachment.height then
1573 video_tag = string.format(
1574 ' <video controls preload="metadata" width="%d" height="%d" style="display:block; max-width:min(100%%,800px); height:auto">\n' ..
1575 ' <source src="%s" type="%s">\n' ..
1576 ' Your browser does not support the video element.\n' ..
1577 ' </video>',
1578 attachment.width, attachment.height, media_src, media_type
1579 )
1580 else
1581 video_tag = string.format(
1582 ' <video controls preload="metadata" style="display:block; max-width:min(100%%,800px); height:auto">\n' ..
1583 ' <source src="%s" type="%s">\n' ..
1584 ' Your browser does not support the video element.\n' ..
1585 ' </video>',
1586 media_src, media_type
1587 )
1588 end
1589 table.insert(media_html, video_tag)
1590 end
1591 end
1592
1593 if #media_html == 0 then
1594 return ""
1595 end
1596
1597 -- Issue 8-005 Fix: Close </pre> before media, reopen after
1598 -- Media inside <pre> don't respect max-width:100% because <pre> sizes to content
1599 -- By closing </pre>, media inherit width constraints from the parent <td> container
1600 return "\n</pre>\n" .. table.concat(media_html, "\n") .. "\n<pre>\n"
1601end
1602-- }}}
1603
1604-- {{{ function render_attachment_images_txt
1605-- Issue 8-049: Now handles all media types (images, audio, video)
1606local function render_attachment_images_txt(attachments)
1607 -- Render plain text placeholders for poem attachments (images, audio, video)
1608 -- Returns [Image: alt-text], [Audio: filename], [Video: filename] format for TXT export
1609 -- Unlike render_attachment_images(), this outputs plain text, not HTML
1610 --
1611 -- This function exists because TXT exports cannot contain HTML media tags.
1612 -- Media are replaced with bracketed descriptions.
1613
1614 if not attachments or #attachments == 0 then
1615 return ""
1616 end
1617
1618 local media_lines = {}
1619
1620 for _, attachment in ipairs(attachments) do
1621 local media_type = attachment.media_type or ""
1622 local placeholder
1623
1624 if media_type:match("^image/") then
1625 -- Use alt text if available, otherwise indicate no description
1626 local alt_text = attachment.description or attachment.alt_text or "no description"
1627 placeholder = string.format("[Image: %s]", alt_text)
1628
1629 elseif media_type:match("^audio/") then
1630 -- Issue 8-049: Audio placeholder
1631 local basename = (attachment.relative_path or ""):match("([^/]+)$") or "audio file"
1632 placeholder = string.format("[Audio: %s]", basename)
1633
1634 elseif media_type:match("^video/") then
1635 -- Issue 8-049: Video placeholder
1636 local basename = (attachment.relative_path or ""):match("([^/]+)$") or "video file"
1637 placeholder = string.format("[Video: %s]", basename)
1638 end
1639
1640 if placeholder then
1641 -- Wrap long text to 80 characters
1642 if #placeholder > 80 then
1643 placeholder = wrap_text_80_chars(placeholder)
1644 end
1645 table.insert(media_lines, placeholder)
1646 end
1647 end
1648
1649 if #media_lines == 0 then
1650 return ""
1651 end
1652
1653 -- Return with newline prefix/suffix for proper spacing
1654 return "\n" .. table.concat(media_lines, "\n") .. "\n"
1655end
1656-- }}}
1657
1658-- {{{ function format_warning_box
1659local function format_warning_box(warning_text)
1660 -- Create simple ASCII box around content warning
1661 local content = wrap_text_80_chars(warning_text)
1662 local lines = {}
1663 for line in content:gmatch("[^\n]+") do
1664 table.insert(lines, line)
1665 end
1666
1667 -- Find longest line for box width
1668 local max_width = 0
1669 for _, line in ipairs(lines) do
1670 max_width = math.max(max_width, #line)
1671 end
1672
1673 -- Ensure minimum width and maximum of 76 chars (leave room for box borders)
1674 max_width = math.min(math.max(max_width, 20), 76)
1675
1676 local boxed = {}
1677 table.insert(boxed, "┌" .. string.rep("─", max_width + 2) .. "┐")
1678
1679 for _, line in ipairs(lines) do
1680 local padded = line .. string.rep(" ", max_width - #line)
1681 table.insert(boxed, "│ " .. padded .. " │")
1682 end
1683
1684 table.insert(boxed, "└" .. string.rep("─", max_width + 2) .. "┘")
1685
1686 return table.concat(boxed, "\n")
1687end
1688-- }}}
1689
1690-- {{{ function escape_html
1691local function escape_html(text)
1692 -- Escape HTML special characters in poem content to prevent browser interpretation
1693 -- Issue 8-041: Fixes bug where poem content containing </pre> breaks page rendering
1694 -- IMPORTANT: Must be called BEFORE apply_markdown_formatting() so that
1695 -- markdown-generated HTML tags (like <em>) are NOT escaped
1696 -- Order matters: & must be escaped first, otherwise &lt; becomes &amp;lt;
1697 if not text then return "" end
1698 return text
1699 -- Strip NUL and other C0 control bytes that occasionally ride along in
1700 -- source poem text (a stray \0 in one post is what made a chronological
1701 -- page read as "binary" and could make a browser choke on it). Keep the
1702 -- legitimate whitespace controls: tab (\9), newline (\10), CR (\13).
1703 :gsub("[%z\1-\8\11\12\14-\31]", "")
1704 :gsub("&", "&amp;")
1705 :gsub("<", "&lt;")
1706 :gsub(">", "&gt;")
1707end
1708-- }}}
1709
1710-- {{{ function apply_markdown_formatting
1711local function apply_markdown_formatting(text)
1712 -- Issue 4-003 (August 2026): emphasis renders styled AND keeps the typed
1713 -- delimiters visible ("both", per user) -- *love* shows as italic *love*.
1714 -- Delimiters already consumed into output are re-emitted as \1 sentinels
1715 -- so the narrower single-asterisk pass cannot re-match inside a bold span;
1716 -- escape_html strips control bytes beforehand, so \1 cannot collide with
1717 -- poem content. Sentinels become literal asterisks at the end.
1718
1719 -- Handle *\*text*\* (legacy escaped-italics convention, kept first so the
1720 -- plain passes below never half-consume its backslash form)
1721 text = text:gsub("%*\\%*([^%*]+)%*\\%*", "<em>\1%1\1</em>")
1722
1723 -- Handle **text** (bold) before *text* so the pair is not split in two
1724 text = text:gsub("%*%*([^%*]+)%*%*", "<strong>\1\1%1\1\1</strong>")
1725
1726 -- Handle *text* (italics). The content must start and end on non-space
1727 -- and stay on one line: "2 * 3 * 4" and asterisk bullet lists are not
1728 -- emphasis. (Two passes because Lua patterns have no alternation: one
1729 -- for 2+ character spans, one for the single-character *x* case.)
1730 text = text:gsub("%*([^%s%*][^%*\n]-[^%s%*])%*", "<em>\1%1\1</em>")
1731 text = text:gsub("%*([^%s%*])%*", "<em>\1%1\1</em>")
1732
1733 -- Handle ~~text~~ (strikethrough) and `text` (inline code)
1734 text = text:gsub("~~([^~]+)~~", "<del>~~%1~~</del>")
1735 text = text:gsub("`([^`\n]+)`", "<code>`%1`</code>")
1736
1737 -- Sentinels back to the asterisks the author typed
1738 text = text:gsub("\1", "*")
1739
1740 return text
1741end
1742-- }}}
1743
1744-- {{{ function is_golden_poem
1745local function is_golden_poem(poem)
1746 -- Issue 8-044: Use pre-calculated golden status from extraction metadata
1747 -- This correctly accounts for:
1748 -- - Pre-anonymization content (original @mentions preserved)
1749 -- - Content warning text (without "CW: " prefix)
1750 -- The extraction calculates this once; we use metadata as single source of truth
1751 if poem.metadata and poem.metadata.is_golden_poem then
1752 return true
1753 end
1754 return false
1755end
1756-- }}}
1757
1758-- {{{ function is_boost_poem
1759local function is_boost_poem(poem)
1760 -- Issue 8-057: Detect boosted/shared posts for visual formatting
1761 -- Boosts are reshared content from other fediverse users
1762 -- boost_type can be: "cached_external", "external", or "embedded"
1763 if poem.metadata and poem.metadata.is_boost then
1764 return true
1765 end
1766 return false
1767end
1768-- }}}
1769
1770-- {{{ function get_poem_display_filename
1771local function get_poem_display_filename(poem)
1772 -- Returns the display filename for a poem (without extension)
1773 -- For notes: uses metadata.source_file (the original filename)
1774 -- For fediverse/messages: uses the numeric ID
1775 -- All categories: no .txt extension (cleaner display)
1776 local category = poem.category or "unknown"
1777 local filename
1778
1779 if category == "notes" and poem.metadata and poem.metadata.source_file then
1780 -- Notes preserve their original descriptive filenames
1781 filename = poem.metadata.source_file
1782 else
1783 -- Fediverse and messages use numeric ID
1784 filename = tostring(poem.id or "unknown")
1785 end
1786
1787 return category .. "/" .. filename
1788end
1789-- }}}
1790
1791-- {{{ function generate_corner_box_separator
1792local function generate_corner_box_separator(hex_color)
1793 -- Generate the separator line with corner box tops for GOLDEN poems
1794 -- Format: ╟─────────┐ ┌───────────┤
1795 -- Left box: 11 chars (╟ + 9×─ + ┐)
1796 -- Right box: 13 chars (┌ + 11×─ + ┤)
1797 -- Gap: 60 chars (spaces)
1798 -- Total: 84 chars
1799 -- The left junction ╟ is colored to match the progress bar
1800 local colored_junction = string.format('<font color="%s"><b>╟</b></font>', hex_color)
1801 local left_box = colored_junction .. string.rep("─", 9) .. "┐"
1802 local right_box = "┌" .. string.rep("─", 11) .. "┤"
1803 local gap = string.rep(" ", 60)
1804 return left_box .. gap .. right_box
1805end
1806-- }}}
1807
1808-- {{{ function colorize_char
1809-- Helper to wrap a character in color tags
1810local function colorize_char(char, hex_color)
1811 if hex_color then
1812 return string.format('<font color="%s"><b>%s</b></font>', hex_color, char)
1813 end
1814 return char
1815end
1816-- }}}
1817
1818-- {{{ function generate_regular_corner_box_top
1819-- Issue 8-035: Added progress_chars and hex_color for progressive colorization
1820local function generate_regular_corner_box_top(progress_chars, hex_color)
1821 -- Generate the top line of corner boxes for REGULAR poems (no side walls)
1822 -- Format: ┌─────────┐ ┌───────────┐
1823 -- Left box: 11 chars (┌ + 9×─ + ┐) at positions 0-10
1824 -- Right box: 13 chars (┌ + 11×─ + ┐) at positions 70-82
1825 -- Gap: 59 chars (spaces) at positions 11-69
1826 -- Total: 83 chars
1827
1828 progress_chars = progress_chars or 0
1829
1830 -- Left box (positions 0-10)
1831 local left_parts = {}
1832 -- Position 0: ┌
1833 table.insert(left_parts, progress_chars > 0 and colorize_char("┌", hex_color) or "┌")
1834 -- Positions 1-9: ─────────
1835 for i = 1, 9 do
1836 table.insert(left_parts, progress_chars > i and colorize_char("─", hex_color) or "─")
1837 end
1838 -- Position 10: ┐
1839 table.insert(left_parts, progress_chars > 10 and colorize_char("┐", hex_color) or "┐")
1840
1841 -- Gap (positions 11-69) - spaces don't need coloring
1842 local gap = string.rep(" ", 59)
1843
1844 -- Right box (positions 70-82)
1845 local right_parts = {}
1846 -- Position 70: ┌
1847 table.insert(right_parts, progress_chars > 70 and colorize_char("┌", hex_color) or "┌")
1848 -- Positions 71-81: ───────────
1849 for i = 71, 81 do
1850 table.insert(right_parts, progress_chars > i and colorize_char("─", hex_color) or "─")
1851 end
1852 -- Position 82: ┐
1853 table.insert(right_parts, progress_chars > 82 and colorize_char("┐", hex_color) or "┐")
1854
1855 return table.concat(left_parts) .. gap .. table.concat(right_parts)
1856end
1857-- }}}
1858
1859-- {{{ function generate_regular_corner_box_bottom
1860local function generate_regular_corner_box_bottom()
1861 -- Generate the bottom line of corner boxes for REGULAR poems
1862 -- Format: └─────────┘ └───────────┘
1863 -- Gap: 59 chars, Total: 83 chars
1864 local left_box = "└" .. string.rep("─", 9) .. "┘"
1865 local right_box = "└" .. string.rep("─", 11) .. "┘"
1866 local gap = string.rep(" ", 59)
1867 return left_box .. gap .. right_box
1868end
1869-- }}}
1870
1871-- {{{ function generate_corner_box_nav_line
1872local function generate_corner_box_nav_line(similar_link, different_link, chronological_link, hex_color)
1873 -- Generate the navigation line with corner box walls for GOLDEN poems (Issue 8-030)
1874 -- Format: ║ similar │ chronological │ different │
1875 -- Left box: ║ + space + link + space + │ = 11 chars
1876 -- Center text: chronological (13 chars visible) - or empty space if nil (on chronological.html)
1877 -- Right box: │ + space + link + space + │ = 13 chars
1878 -- Gaps: 2 gaps of ~23 chars each
1879 -- Total: 84 chars
1880 -- The left wall ║ is colored to match the progress bar
1881
1882 -- The links contain HTML, so we need to measure visible text
1883 local similar_visible = similar_link:gsub("<[^>]+>", "") -- "similar"
1884 local different_visible = different_link:gsub("<[^>]+>", "") -- "different"
1885
1886 -- Handle nil chronological_link (on chronological.html page, we don't show this link)
1887 local center_text = ""
1888 local center_visible_len = 0
1889 if chronological_link then
1890 center_text = chronological_link
1891 center_visible_len = chronological_link:gsub("<[^>]+>", ""):len() -- "chronological" = 13 chars
1892 end
1893
1894 -- Left box: ║ (colored) + space + similar + padding + │
1895 local colored_wall = string.format('<font color="%s"><b>║</b></font>', hex_color)
1896 local left_content_width = 9 -- space between ║ and │
1897 local similar_padding = left_content_width - 1 - #similar_visible -- 1 for leading space
1898 local left_box = colored_wall .. " " .. similar_link .. string.rep(" ", similar_padding) .. "│"
1899
1900 -- Right box: │ + space + different + padding + │
1901 local right_content_width = 11 -- space between │ and │
1902 local different_padding = right_content_width - 1 - #different_visible -- 1 for leading space
1903 local right_box = "│ " .. different_link .. string.rep(" ", different_padding) .. "│"
1904
1905 -- Calculate gaps: Total 84 - 11 (left) - center_visible - 13 (right) = remaining
1906 -- If no center text, distribute all 47+13 = 60 chars into the gaps (30 left, 30 right)
1907 -- If center text (13 chars), split remaining 47 into 22 left + 25 right
1908 local left_gap, right_gap
1909 if center_visible_len > 0 then
1910 left_gap = string.rep(" ", 22)
1911 right_gap = string.rep(" ", 25)
1912 else
1913 -- No chronological link - distribute 60 chars evenly (30+30)
1914 left_gap = string.rep(" ", 30)
1915 right_gap = string.rep(" ", 30)
1916 end
1917
1918 return left_box .. left_gap .. center_text .. right_gap .. right_box
1919end
1920-- }}}
1921
1922-- {{{ function generate_regular_corner_box_nav_line
1923-- Issue 8-035: Added progress_chars and hex_color for progressive colorization
1924local function generate_regular_corner_box_nav_line(similar_link, different_link, chronological_link, progress_chars, hex_color)
1925 -- Generate the navigation line with corner box walls for REGULAR poems (Issue 8-030)
1926 -- Format: │ similar │ chronological │ different │
1927 -- Left box: │ + space + link + space + │ = 11 chars (positions 0-10)
1928 -- Center text: chronological (13 chars visible) - or empty space if nil (on chronological.html)
1929 -- Right box: │ + space + link + space + │ = 13 chars (positions 70-82)
1930 -- Gaps: 2 gaps totaling 59 chars (with 13 char center text: 23 left + 23 right)
1931 -- Total: 83 chars
1932
1933 progress_chars = progress_chars or 0
1934
1935 local similar_visible = similar_link:gsub("<[^>]+>", "")
1936 local different_visible = different_link:gsub("<[^>]+>", "")
1937
1938 -- Handle nil chronological_link (on chronological.html page, we don't show this link)
1939 local center_text = ""
1940 local center_visible_len = 0
1941 if chronological_link then
1942 center_text = chronological_link
1943 center_visible_len = chronological_link:gsub("<[^>]+>", ""):len() -- "chronological" = 13 chars
1944 end
1945
1946 -- Left box: │ + space + similar + padding + │
1947 -- Wall characters at positions 0 and 10
1948 local left_wall = progress_chars > 0 and colorize_char("│", hex_color) or "│"
1949 local right_wall_of_left = progress_chars > 10 and colorize_char("│", hex_color) or "│"
1950 local left_content_width = 9
1951 local similar_padding = left_content_width - 1 - #similar_visible
1952 local left_box = left_wall .. " " .. similar_link .. string.rep(" ", similar_padding) .. right_wall_of_left
1953
1954 -- Right box: │ + space + different + padding + │
1955 -- Wall characters at positions 70 and 82
1956 local left_wall_of_right = progress_chars > 70 and colorize_char("│", hex_color) or "│"
1957 local right_wall = progress_chars > 82 and colorize_char("│", hex_color) or "│"
1958 local right_content_width = 11
1959 local different_padding = right_content_width - 1 - #different_visible
1960 local right_box = left_wall_of_right .. " " .. different_link .. string.rep(" ", different_padding) .. right_wall
1961
1962 -- Calculate gaps: Total 83 - 11 (left) - 13 (right) = 59 for gaps + center
1963 -- If no center text, distribute 59 chars into the gaps (29 left, 30 right)
1964 -- If center text (13 chars), split remaining 46 into 23 left + 23 right
1965 local left_gap, right_gap
1966 if center_visible_len > 0 then
1967 left_gap = string.rep(" ", 23)
1968 right_gap = string.rep(" ", 23)
1969 else
1970 -- No chronological link - distribute 59 chars (29+30)
1971 left_gap = string.rep(" ", 29)
1972 right_gap = string.rep(" ", 30)
1973 end
1974
1975 return left_box .. left_gap .. center_text .. right_gap .. right_box
1976end
1977-- }}}
1978
1979-- {{{ function apply_golden_poem_formatting
1980local function apply_golden_poem_formatting(content, is_golden, similar_link, different_link, chronological_link, hex_color)
1981 -- Golden poem side borders: ║ on left (colored), │ on right
1982 -- Interior width: 80 characters for content (with 1 space padding on each side)
1983 -- Format: ║ + space + 80 chars content (padded) + space + │ = 84 total
1984 -- The left wall ║ is colored to match the progress bar
1985 if not is_golden then
1986 return content
1987 end
1988
1989 local CONTENT_WIDTH = 80 -- Content area between padding spaces
1990 local color = hex_color or "#787878" -- Default to gray if no color provided
1991
1992 -- Helper to count UTF-8 characters (not bytes)
1993 -- Box-drawing chars are 3 bytes each, so #str gives wrong count
1994 local function utf8_char_count(str)
1995 -- Remove UTF-8 continuation bytes (0x80-0xBF), count what remains
1996 return #(str:gsub("[\128-\191]", ""))
1997 end
1998
1999 -- Split content into lines (append newline to handle last line without trailing newline)
2000 local lines = {}
2001 for line in (content .. "\n"):gmatch("(.-)\n") do
2002 table.insert(lines, line)
2003 end
2004
2005 local formatted_lines = {}
2006 local colored_wall = string.format('<font color="%s"><b>║</b></font>', color)
2007
2008 for _, line in ipairs(lines) do
2009 -- Calculate visible length (excluding HTML tags, counting UTF-8 chars)
2010 -- Issue 8-055: Also decode HTML entities for accurate width counting
2011 -- e.g., &gt; is 4 bytes but displays as 1 character (>)
2012 local visible_length = text_formatter.calculate_visible_width(line)
2013
2014 -- Pad or handle line to fit content width
2015 local padded_line
2016 if visible_length >= CONTENT_WIDTH then
2017 -- Line is already at or over width - use as-is
2018 padded_line = line
2019 else
2020 -- Pad with spaces to reach content width
2021 local padding_needed = CONTENT_WIDTH - visible_length
2022 padded_line = line .. string.rep(" ", padding_needed)
2023 end
2024
2025 -- Add side borders with padding: ║ (colored) content │
2026 table.insert(formatted_lines, colored_wall .. " " .. padded_line .. " │")
2027 end
2028
2029 -- Add corner box navigation (separator + nav line) if links provided
2030 -- Issue 9-003 Fix: Only require similar and different links - chronological_link can be nil
2031 if similar_link and different_link then
2032 -- Add separator line with corner box tops: ╟─────────┐ ┌───────────┤
2033 table.insert(formatted_lines, generate_corner_box_separator(color))
2034 -- Add navigation line with corner box walls: ║ similar │ chronological │ different │
2035 -- chronological_link may be nil on chronological.html (shows empty space in center)
2036 table.insert(formatted_lines, generate_corner_box_nav_line(similar_link, different_link, chronological_link, color))
2037 end
2038
2039 return table.concat(formatted_lines, "\n")
2040end
2041-- }}}
2042
2043-- {{{ Issue 8-057: Boost Visual Formatting Functions
2044-- Boosts use nested frames: outer blue frame + inner teal content box with
2045-- asymmetric arrows (◀═ top-left, ─▶ bottom-right) and a floating [BOOST] label.
2046-- ALL geometry now lives in src/boost-bars.lua (shared, unit-tested) -- the old
2047-- generate_boost_* helpers were removed because three drifting copies produced
2048-- misaligned walls, wrong junction columns, and ▢ corruption. This path keeps
2049-- only the thin assembler below.
2050
2051-- {{{ function apply_boost_poem_formatting
2052local function apply_boost_poem_formatting(content, progress_percent, similar_link, different_link, chronological_link)
2053 -- Issue 8-057: nested frame formatting for boosts, drawn by the shared
2054 -- boost-bars module (single source of truth for every render path). We just
2055 -- split the pre-wrapped content into lines; the module owns all geometry.
2056 local lines = {}
2057 for line in (content .. "\n"):gmatch("(.-)\n") do
2058 table.insert(lines, line)
2059 end
2060 local include_nav = (similar_link and different_link) and true or false
2061 return boost_bars.format_boost(
2062 lines, progress_percent, similar_link, different_link, chronological_link, include_nav)
2063end
2064-- }}}
2065
2066-- }}} End Issue 8-057: Boost Visual Formatting Functions
2067
2068-- {{{ function format_content_with_warnings
2069local function format_content_with_warnings(text, poem_category, poem, similar_link, different_link, chronological_link, hex_color)
2070 -- Issue 8-041: Escape HTML special characters in poem content FIRST
2071 -- This prevents browser from interpreting poem content as HTML markup
2072 -- (e.g., a poem containing "</pre>" would otherwise close the preformatted block)
2073 text = escape_html(text)
2074
2075 -- Apply markdown formatting AFTER escaping
2076 -- This allows *italics* to become <em>italics</em> while keeping
2077 -- literal < > & in poem content safely escaped
2078 text = apply_markdown_formatting(text)
2079
2080 -- Check if this is a golden poem
2081 local is_golden = poem and is_golden_poem(poem)
2082
2083 local formatted_lines = {}
2084
2085 -- Issue 9-011: Display content warning from poem.content_warning field (Mastodon CW)
2086 -- This is separate from in-content CW: patterns - it comes from ActivityPub summary field
2087 if poem and poem.content_warning and poem.content_warning ~= "" then
2088 local cw_label = "CW: " .. poem.content_warning
2089 local warning_box = format_warning_box(cw_label)
2090 table.insert(formatted_lines, warning_box)
2091 table.insert(formatted_lines, "") -- First newline
2092 table.insert(formatted_lines, "") -- Second newline for spacing
2093 end
2094
2095 -- Detect additional content warning patterns in text (CW:, content warning:, etc.)
2096 -- Issue 10-021: Use text_formatter.format_poem_lines to preserve empty lines (paragraph breaks)
2097 local lines = text_formatter.format_poem_lines(text)
2098
2099 for _, line in ipairs(lines) do
2100 -- Check if line starts with content warning (in-content CW pattern)
2101 if line:lower():match("^%s*cw%s*:") or line:lower():match("^%s*content warning%s*:") then
2102 -- Format content warning with box
2103 local warning_box = format_warning_box(line)
2104 table.insert(formatted_lines, warning_box)
2105 table.insert(formatted_lines, "") -- First newline
2106 table.insert(formatted_lines, "") -- Second newline for spacing
2107 else
2108 -- Issue 10-021: Wrap long lines while preserving leading whitespace
2109 -- This replaces 8-056's no-wrap approach with whitespace-aware wrapping
2110 local wrapped = text_formatter.wrap_preserving_indent(line, 80)
2111 for _, wrapped_line in ipairs(wrapped) do
2112 table.insert(formatted_lines, wrapped_line)
2113 end
2114 end
2115 end
2116
2117 local formatted_content = table.concat(formatted_lines, "\n")
2118
2119 -- Apply golden poem box-drawing formatting (with corner box nav inside)
2120 if is_golden then
2121 formatted_content = apply_golden_poem_formatting(formatted_content, true, similar_link, different_link, chronological_link, hex_color)
2122 else
2123 -- For regular poems, add 1-space left padding to each content line
2124 -- Content uses 1-space indent for alignment (83 chars total width)
2125 local padded_lines = {}
2126 for line in (formatted_content .. "\n"):gmatch("(.-)\n") do
2127 table.insert(padded_lines, " " .. line)
2128 end
2129 formatted_content = table.concat(padded_lines, "\n")
2130 end
2131
2132 return formatted_content, is_golden
2133end
2134-- }}}
2135
2136-- One warning per process, not per poem. The check that needs announcing lives
2137-- inside the per-poem formatter, and a full build formats roughly 700,000
2138-- entries -- warning at each one would bury the message it is trying to deliver.
2139local chrono_fallback_warned = false
2140
2141-- {{{ local function warn_chrono_fallback_once
2142-- Announces that a chronological link could not be aimed at a real page number.
2143-- The reason string names WHICH way the mapping failed, so the caller that
2144-- dropped it can be found without re-deriving this whole path.
2145local function warn_chrono_fallback_once(reason)
2146 if chrono_fallback_warned then return end
2147 chrono_fallback_warned = true
2148 utils.log_warn(string.format(
2149 "chronological links fell back to chronological/index.html (%s) - " ..
2150 "the #poem anchor is lost across that redirect, so every link lands at " ..
2151 "the top of the chronological view instead of at its poem", reason))
2152end
2153-- }}}
2154
2155-- {{{ function format_single_poem_with_progress_and_color
2156-- Issue 10-036: Added chrono_mapping for correct paginated chronological links
2157-- chrono_paginated: whether the chronological view was split into numbered pages.
2158-- It cannot be read from PAGINATION_CONFIG here, because --chrono-per-page
2159-- turns pagination on at runtime without touching the config table.
2160local function format_single_poem_with_progress_and_color(poem, total_poems, poem_colors, chrono_mapping, chrono_paginated)
2161 -- Issue 9-013: a ranked IMAGE entry (pseudo-poem) renders as an image box,
2162 -- not a poem. Inert until inject_pseudo_poems tags/append image entries.
2163 if poem.is_image then
2164 return image_render.format_image_entry(poem)
2165 end
2166
2167 local formatted = ""
2168
2169 -- Get semantic color for this poem (key by poem_index, NOT poem.id)
2170 local poem_color_data = poem_colors[poem.poem_index]
2171 local semantic_color = poem_color_data and poem_color_data.color or "gray"
2172 local hex_color = COLOR_CONFIG[semantic_color] or COLOR_CONFIG["gray"]
2173
2174 -- Calculate chronological progress (using poem_index for lookup)
2175 local progress_info = calculate_chronological_progress(poem.poem_index, total_poems)
2176
2177 -- Check if this is a golden poem (exactly 1024 characters)
2178 local is_golden = is_golden_poem(poem)
2179
2180 -- Issue 8-057: Check if this is a boost (reshared content from another author)
2181 local is_boost = is_boost_poem(poem)
2182
2183 -- Build navigation links for this poem (using category prefix for anchors, poem_index for paginated files)
2184 local unique_id = get_unique_poem_filename_id(poem) -- For anchor IDs only (e.g. "messages-0001")
2185 local anchor_id = get_poem_anchor_id(poem)
2186 local poem_index = poem.poem_index or 0 -- Numeric ID for paginated files (e.g. 1 → "0001")
2187
2188 -- Issue 8-012 Phase E: Link to paginated format (similar/0001-01.html)
2189 -- Issue 9-003: Use absolute file:// paths - helper script converts to production URLs
2190 local base_path = ".."
2191 local similar_link = string.format("<a href='%s/similar/%04d-01.html'>similar</a>", base_path, poem_index)
2192 local different_link = string.format("<a href='%s/different/%04d-01.html'>different</a>", base_path, poem_index)
2193 -- Issue 8-039: Chronological now in subdirectory
2194 -- Issue 10-036: the link must name the chronological page that actually holds
2195 -- this poem, and TWO facts decide that filename -- both have to reach here:
2196 -- chrono_mapping poem_index -> {page_number, total_pages, ...}
2197 -- chrono_paginated whether the view was split into numbered pages at all
2198 -- Paginated builds write chronological/NN.html; unpaginated builds write only
2199 -- chronological/index.html. That branch is the one in
2200 -- generate_chronological_index_with_navigation, and this link has to agree
2201 -- with it or it names a file that was never written.
2202 --
2203 -- The old code guessed "01" whenever the mapping was absent. That guess is
2204 -- how a full build shipped 694,530 links all pointing at chronological page
2205 -- 1: this sequential path ran with chrono_mapping = nil, so every poem on
2206 -- every similar/different page claimed to live among the first 88 poems.
2207 -- A guess that is silently wrong for 99% of a corpus is worse than a stop,
2208 -- so the fallback now goes to index.html -- the one file guaranteed to exist
2209 -- in BOTH modes -- and says out loud that it gave up the anchor.
2210 local chrono_info = chrono_mapping and chrono_mapping[poem_index]
2211 local chronological_link
2212 if chrono_info and chrono_paginated and (chrono_info.total_pages or 1) > 1 then
2213 chronological_link = string.format("<a href='%s/chronological/%02d.html#%s'>chronological</a>",
2214 base_path, chrono_info.page_number, anchor_id)
2215 else
2216 -- Unpaginated with a real mapping is the correct, quiet case: index.html
2217 -- IS the whole chronological view, and the anchor resolves inside it.
2218 if not chrono_mapping then
2219 warn_chrono_fallback_once("no chronological mapping reached this generator")
2220 elseif not chrono_info then
2221 warn_chrono_fallback_once(string.format(
2222 "poem_index %d is absent from the chronological mapping", poem_index))
2223 end
2224 chronological_link = string.format("<a href='%s/chronological/index.html#%s'>chronological</a>",
2225 base_path, anchor_id)
2226 end
2227
2228 -- Add file header (notes show original filename, others show numeric ID)
2229 formatted = formatted .. string.format(" -> file: %s\n", get_poem_display_filename(poem))
2230 -- Issue 9-013: text+image posts get a direct "image.png" link below the
2231 -- header. (Image entries never reach here -- they return early above.)
2232 local img_link = image_render.text_image_link(poem)
2233 if img_link ~= "" then formatted = formatted .. " " .. img_link .. "\n" end
2234
2235 -- Issue 8-057: Boost formatting - uses complete nested frame with arrows and [BOOST] label
2236 -- Boost formatting replaces all standard elements (top bar, content, nav, bottom bar)
2237 if is_boost then
2238 -- Escape HTML and apply markdown to content
2239 local text = escape_html(poem.content or "")
2240
2241 -- Issue 10-037: Defensive fallback for blank boost content
2242 -- If content is empty, display the original URI or diagnostic message
2243 if text == "" or text:match("^%s*$") then
2244 local original_uri = poem.metadata and poem.metadata.original_uri
2245 if original_uri then
2246 text = "External post: " .. escape_html(original_uri)
2247 else
2248 text = "(Boost content unavailable)"
2249 end
2250 end
2251
2252 -- Issue 10-039: Make external boost URLs clickable
2253 -- Pattern: "External post: https://..." -> wrap URL in anchor tag
2254 local external_pattern = "^External post: (https?://[^%s]+)$"
2255 local external_url = text:match(external_pattern)
2256 if external_url then
2257 -- Wrap the URL across box lines (boost content width) instead of
2258 -- letting it overflow the box; each line links to the full URL.
2259 text = text_formatter.wrap_external_url("External post: ", external_url, boost_bars.CONTENT_WIDTH)
2260 else
2261 -- Issue 10-041: Wrap long embedded content to fit the boost box.
2262 -- Only wrap non-external-post content (external posts keep URLs intact)
2263 local BOOST_CONTENT_WIDTH = boost_bars.CONTENT_WIDTH
2264 local wrapped_lines = {}
2265 for line in (text .. "\n"):gmatch("(.-)\n") do
2266 local wrapped = text_formatter.wrap_preserving_indent(line, BOOST_CONTENT_WIDTH)
2267 for _, wrapped_line in ipairs(wrapped) do
2268 table.insert(wrapped_lines, wrapped_line)
2269 end
2270 end
2271 text = table.concat(wrapped_lines, "\n")
2272 end
2273
2274 text = apply_markdown_formatting(text)
2275
2276 -- Calculate progress as decimal (0-1) for boost functions
2277 local progress_percent = progress_info.percentage / 100
2278
2279 -- Apply complete boost formatting (includes all frame elements)
2280 local boost_formatted = apply_boost_poem_formatting(
2281 text, progress_percent, similar_link, different_link, chronological_link
2282 )
2283 formatted = formatted .. boost_formatted .. "\n"
2284
2285 -- Render attached images after boost frame
2286 if poem.attachments then
2287 formatted = formatted .. render_attachment_images(poem.attachments)
2288 end
2289
2290 return {
2291 content = formatted,
2292 semantic_color = semantic_color,
2293 progress_percentage = progress_info.percentage,
2294 poem_id = poem.id
2295 }
2296 end
2297
2298 -- Standard formatting for golden and regular poems
2299 -- Generate top progress bar separator (with golden corners if applicable)
2300 local top_dashes = generate_progress_dashes(progress_info, semantic_color, is_golden, "top")
2301 formatted = formatted .. string.format('<span %s>%s</span>',
2302 top_dashes.accessibility,
2303 top_dashes.visual)
2304
2305 -- Add newline after top border for all poems
2306 -- Golden poems: ┐ corner needs newline before ║ content wall on next line
2307 -- Regular poems: progress bar needs newline before content
2308 formatted = formatted .. "\n"
2309
2310 -- Format poem content with content warning handling and whitespace preservation
2311 -- Pass nav links and hex_color for golden poems
2312 local content_formatted = format_content_with_warnings(
2313 poem.content or "", poem.category, poem,
2314 is_golden and similar_link or nil,
2315 is_golden and different_link or nil,
2316 is_golden and chronological_link or nil,
2317 is_golden and hex_color or nil
2318 )
2319 formatted = formatted .. content_formatted
2320
2321 -- Render attached images if present (from ActivityPub extraction)
2322 -- Images appear after poem content, before navigation links
2323 -- Issue 9-010: Images stay with their original post only (no associated_images rendering)
2324 if poem.attachments then
2325 formatted = formatted .. render_attachment_images(poem.attachments)
2326 end
2327
2328 -- For golden poems, content already includes nav in corner boxes
2329 -- For regular poems, add corner-boxed navigation links (top and nav lines only, bottom connects to progress bar)
2330 if not is_golden then
2331 -- Issue 8-035: Calculate progress_chars and hex_color for nav box colorization
2332 local total_chars = LAYOUT.REGULAR_POEM_WIDTH
2333 local progress_chars = math.floor((progress_info.percentage / 100) * total_chars)
2334 local hex_color = COLOR_CONFIG[semantic_color]
2335
2336 formatted = formatted .. "\n"
2337 formatted = formatted .. generate_regular_corner_box_top(progress_chars, hex_color) .. "\n"
2338 formatted = formatted .. generate_regular_corner_box_nav_line(similar_link, different_link, chronological_link, progress_chars, hex_color) .. "\n"
2339 -- No bottom line - corner boxes connect directly to progress bar via junctions
2340 else
2341 -- Golden poems: add newline after nav line (content_formatted doesn't end with newline)
2342 formatted = formatted .. "\n"
2343 end
2344
2345 -- Generate bottom progress bar separator (with junctions for both golden and regular poems)
2346 -- The has_corner_boxes parameter enables junction characters at wall positions
2347 local bottom_dashes = generate_progress_dashes(progress_info, semantic_color, is_golden, "bottom", true)
2348 formatted = formatted .. string.format('<span %s>%s</span>\n',
2349 bottom_dashes.accessibility,
2350 bottom_dashes.visual)
2351
2352 return {
2353 content = formatted,
2354 semantic_color = semantic_color,
2355 progress_percentage = progress_info.percentage,
2356 poem_id = poem.id
2357 }
2358end
2359-- }}}
2360
2361-- {{{ function format_single_poem_with_warnings
2362local function format_single_poem_with_warnings(poem)
2363 local formatted = ""
2364
2365 -- Add file header (notes show original filename, others show numeric ID)
2366 formatted = formatted .. string.format(" -> file: %s\n", get_poem_display_filename(poem))
2367 formatted = formatted .. string.rep("-", 80) .. "\n"
2368
2369 -- Format poem content with content warning handling and whitespace preservation
2370 formatted = formatted .. format_content_with_warnings(poem.content or "", poem.category, poem)
2371
2372 -- Render attached images if present
2373 if poem.attachments then
2374 formatted = formatted .. render_attachment_images(poem.attachments)
2375 end
2376
2377 return formatted
2378end
2379-- }}}
2380
2381-- {{{ function format_single_poem_80_width
2382local function format_single_poem_80_width(poem)
2383 -- Format a single poem for TXT export (80-character width, no HTML)
2384 -- Uses strip_html_tags() to remove HTML and render_attachment_images_txt() for images
2385 local formatted = ""
2386
2387 -- Add file header (notes show original filename, others show numeric ID)
2388 formatted = formatted .. string.format(" -> file: %s\n", get_poem_display_filename(poem))
2389 formatted = formatted .. string.rep("-", 80) .. "\n"
2390
2391 -- Strip HTML tags and format poem content to 80-character width
2392 local clean_content = strip_html_tags(poem.content or "")
2393 formatted = formatted .. wrap_text_80_chars(clean_content)
2394
2395 -- Render attached images as [Image: alt-text] placeholders (not HTML)
2396 if poem.attachments then
2397 formatted = formatted .. render_attachment_images_txt(poem.attachments)
2398 end
2399
2400 return formatted
2401end
2402-- }}}
2403
2404-- {{{ function format_all_poems_with_progress_and_color
2405-- Issue 10-036: Added chrono_mapping for correct paginated chronological links
2406local function format_all_poems_with_progress_and_color(starting_poem, sorted_poems, total_poems, poem_colors, chrono_mapping, chrono_paginated)
2407 local content = ""
2408
2409 -- Add starting poem first with progress visualization
2410 local formatted_starting = format_single_poem_with_progress_and_color(starting_poem, total_poems, poem_colors, chrono_mapping, chrono_paginated)
2411 content = content .. formatted_starting.content .. "\n\n"
2412
2413 -- Add all other poems sorted by similarity/diversity
2414 for _, poem_info in ipairs(sorted_poems) do
2415 if poem_info.id ~= starting_poem.id then -- Skip starting poem since we already added it
2416 local formatted_poem = format_single_poem_with_progress_and_color(poem_info.poem, total_poems, poem_colors, chrono_mapping, chrono_paginated)
2417 content = content .. formatted_poem.content .. "\n\n"
2418 end
2419 end
2420
2421 return content
2422end
2423-- }}}
2424
2425-- {{{ function format_all_poems_with_content_warnings
2426local function format_all_poems_with_content_warnings(starting_poem, sorted_poems)
2427 local content = ""
2428
2429 -- Add starting poem first
2430 content = content .. format_single_poem_with_warnings(starting_poem)
2431 content = content .. "\n\n"
2432
2433 -- Add all other poems sorted by similarity/diversity
2434 for _, poem_info in ipairs(sorted_poems) do
2435 if poem_info.id ~= starting_poem.id then -- Skip starting poem since we already added it
2436 content = content .. format_single_poem_with_warnings(poem_info.poem)
2437 content = content .. "\n\n"
2438 end
2439 end
2440
2441 return content
2442end
2443-- }}}
2444
2445-- {{{ function format_all_poems_80_width
2446local function format_all_poems_80_width(starting_poem, sorted_poems)
2447 local content = ""
2448
2449 -- Add starting poem first
2450 content = content .. format_single_poem_80_width(starting_poem)
2451 content = content .. "\n\n"
2452
2453 -- Add all other poems sorted by similarity/diversity
2454 for _, poem_info in ipairs(sorted_poems) do
2455 if poem_info.id ~= starting_poem.id then -- Skip starting poem since we already added it
2456 content = content .. format_single_poem_80_width(poem_info.poem)
2457 content = content .. "\n\n"
2458 end
2459 end
2460
2461 return content
2462end
2463-- }}}
2464
2465-- {{{ function M.generate_flat_poem_list_html_with_progress
2466-- Issue 10-036: Added chrono_mapping for correct paginated chronological links
2467function M.generate_flat_poem_list_html_with_progress(starting_poem, sorted_poems, page_type, starting_poem_id, use_progress, chrono_mapping, chrono_paginated)
2468 -- Template uses pure HTML without CSS (except Issue 16-010 font-stack)
2469 -- Content is pre-wrapped to 80 chars, <pre> provides monospace formatting
2470 -- Issue 9-003 Fix: Use centered table for block centering with left-aligned text inside
2471 -- Issue 16-010: Added FONT_STYLE for Hack Nerd Font font-stack
2472 local template = [[<!DOCTYPE html>
2473<html>
2474<head>
2475<meta charset="UTF-8">
2476<title>Poems sorted by %s to: %s</title>
2477]] .. FONT_STYLE .. [[</head>
2478<body bgcolor="#000000" text="#FFFFFF" link="#6699FF" vlink="#9966FF">
2479<center>
2480<h1>Poetry Collection</h1>
2481<p>All poems sorted by %s to: %s</p>
2482</center>
2483<table align="center"><tr><td>
2484<pre>
2485%s
2486</pre>
2487</td></tr></table>
2488</body>
2489</html>]]
2490
2491 local formatted_content
2492
2493 if use_progress then
2494 -- Load poem colors and use enhanced formatting
2495 local poem_colors = load_poem_colors()
2496
2497 -- Calculate actual total poems by finding the maximum poem ID
2498 -- This represents the total chronological span of the corpus
2499 local max_poem_id = starting_poem.id or 1
2500
2501 for _, poem_info in ipairs(sorted_poems) do
2502 if poem_info.id and poem_info.id > max_poem_id then
2503 max_poem_id = poem_info.id
2504 elseif poem_info.poem and poem_info.poem.id and poem_info.poem.id > max_poem_id then
2505 max_poem_id = poem_info.poem.id
2506 end
2507 end
2508
2509 local total_poems = max_poem_id
2510
2511 -- Issue 10-036: Pass chrono_mapping for correct paginated chronological links
2512 formatted_content = format_all_poems_with_progress_and_color(starting_poem, sorted_poems, total_poems, poem_colors, chrono_mapping, chrono_paginated)
2513 else
2514 -- Use standard formatting with content warnings
2515 formatted_content = format_all_poems_with_content_warnings(starting_poem, sorted_poems)
2516 end
2517
2518 local page_type_desc = (page_type == "similar") and "similarity" or "difference"
2519 local starting_title = starting_poem.title or ("Poem " .. starting_poem_id)
2520
2521 return string.format(template,
2522 page_type_desc,
2523 starting_title,
2524 page_type_desc,
2525 starting_title,
2526 formatted_content)
2527end
2528-- }}}
2529
2530-- {{{ function M.generate_flat_poem_list_html
2531-- Issue 10-036: Added chrono_mapping for correct paginated chronological links
2532function M.generate_flat_poem_list_html(starting_poem, sorted_poems, page_type, starting_poem_id, chrono_mapping, chrono_paginated)
2533 -- Default to using progress bars
2534 return M.generate_flat_poem_list_html_with_progress(starting_poem, sorted_poems, page_type, starting_poem_id, true, chrono_mapping, chrono_paginated)
2535end
2536-- }}}
2537
2538-- {{{ local function generate_download_links
2539-- Generates download links for the exports sitting beside this page.
2540-- starting_poem: the anchor poem -- the export filenames are derived from it
2541-- total_pages: how many pages this ordering was split into; decides the plural
2542-- Returns: HTML string with download links
2543local function generate_download_links(starting_poem, total_pages)
2544 -- The exports are WRITTEN under get_unique_poem_filename_id, which prefixes
2545 -- the category to dodge cross-category id collisions (Issue 8-019). The file
2546 -- on disk is "fediverse-4355.txt". This used to build the name from a bare
2547 -- %04d of the poem index instead -- "4355.txt" -- naming a file that was
2548 -- never written. Read the name from the same function the writer uses, so
2549 -- the two cannot drift apart again.
2550 local unique_id = get_unique_poem_filename_id(starting_poem)
2551
2552 -- Bare filenames, no directory. These links are document-relative and this
2553 -- page ALREADY lives inside similar/ or different/, so naming the directory
2554 -- again resolved to similar/similar/fediverse-4355.txt. The export is a
2555 -- sibling of this page, not a child of it.
2556 local txt_file = string.format("%s.txt", unique_id)
2557 local html_archive_file = string.format("%s-archive.html", unique_id)
2558
2559 -- The label counts pages, so it has to agree with the pagination the reader
2560 -- is actually looking at rather than assume either case. Missing or zero
2561 -- page counts read as one page, because a page the reader is standing on
2562 -- always exists -- there is no such thing as "download these 0 pages".
2563 local page_count = total_pages or 1
2564 if page_count < 1 then page_count = 1 end
2565 local label = (page_count > 1) and "Download these pages:" or "Download this page:"
2566
2567 local links = {}
2568 table.insert(links, label)
2569 table.insert(links, string.format(' [<a href="%s">.txt</a>]', txt_file))
2570
2571 -- The .html archive is only written when generate_html_archives is on, and
2572 -- it ships off (config.lua: "redundant with paginated pages"). Offering the
2573 -- link regardless put a permanently dead download on every page. Advertise
2574 -- what was actually produced -- see Issue 10-055's rule for the source
2575 -- browser: skip it, do not emit a dead link.
2576 if PAGINATION_CONFIG.generate_html_archives then
2577 table.insert(links, string.format(' [<a href="%s">.html</a>]', html_archive_file))
2578 end
2579
2580 return table.concat(links, " ")
2581end
2582-- }}}
2583
2584-- {{{ function M.generate_paginated_poem_page_html
2585-- Generates a single paginated page with navigation
2586-- starting_poem: the anchor poem object
2587-- sorted_poems: full sorted list of all poems
2588-- page_type: "similar" or "different"
2589-- starting_poem_id: the anchor poem's ID
2590-- page_num: 1-indexed page number
2591-- total_pages: total number of pages (may be capped by max_pages_per_poem)
2592-- total_corpus: optional - total poems in full corpus (for storage context display)
2593-- chrono_mapping: optional - poem_index → {page_number, ...} for correct chronological links
2594-- Returns: HTML string for this specific page
2595-- Updated for Issue 8-020: Passes total_corpus to navigation for storage constraint messaging
2596-- Issue 10-036: Added chrono_mapping for correct paginated chronological links
2597function M.generate_paginated_poem_page_html(starting_poem, sorted_poems, page_type, starting_poem_id, page_num, total_pages, total_corpus, chrono_mapping, chrono_paginated)
2598 -- Ensure pagination config is loaded
2599 load_pagination_config()
2600
2601 -- Get poems for this specific page
2602 local page_poems = get_poems_for_page(sorted_poems, page_num)
2603
2604 if #page_poems == 0 then
2605 utils.log_warn(string.format("No poems found for page %d of %s/%d",
2606 page_num, page_type, starting_poem_id))
2607 return nil
2608 end
2609
2610 -- Use provided total_corpus or calculate from sorted_poems
2611 local corpus_size = total_corpus or #sorted_poems
2612
2613 -- Generate header navigation (with storage context)
2614 local header_nav = generate_prev_next_navigation(page_num, total_pages, starting_poem_id, page_type, corpus_size)
2615
2616 -- Generate footer navigation (same as header)
2617 local footer_nav = generate_prev_next_navigation(page_num, total_pages, starting_poem_id, page_type, corpus_size)
2618
2619 -- Load poem colors for progress bars
2620 local poem_colors = load_poem_colors()
2621
2622 -- Calculate actual total poems (max ID in corpus)
2623 local max_poem_id = starting_poem.id or 1
2624 for _, poem_info in ipairs(sorted_poems) do
2625 local pid = poem_info.id or (poem_info.poem and poem_info.poem.id)
2626 if pid and pid > max_poem_id then
2627 max_poem_id = pid
2628 end
2629 end
2630 local corpus_total = max_poem_id
2631
2632 -- Format the poems for this page
2633 -- Issue 10-036: Pass chrono_mapping for correct paginated chronological links
2634 local formatted_content = format_all_poems_with_progress_and_color(
2635 starting_poem, page_poems, corpus_total, poem_colors, chrono_mapping, chrono_paginated)
2636
2637 -- Build the page
2638 local page_type_desc = (page_type == "similar") and "similarity" or "difference"
2639 local starting_title = starting_poem.title or ("Poem " .. starting_poem_id)
2640 local padded_id = string.format("%04d", starting_poem_id)
2641
2642 -- Generate download links for full-corpus exports
2643 local download_links = generate_download_links(starting_poem, total_pages)
2644
2645 -- Issue 9-003 Fix: Use centered table for block centering with left-aligned text inside
2646 -- Issue 16-010: Added FONT_STYLE for Hack Nerd Font font-stack
2647 local template = [[<!DOCTYPE html>
2648<html>
2649<head>
2650<meta charset="UTF-8">
2651<title>Poems sorted by %s to: %s (Page %d of %d)</title>
2652]] .. FONT_STYLE .. [[</head>
2653<body bgcolor="#000000" text="#FFFFFF" link="#6699FF" vlink="#9966FF">
2654<center>
2655<h1>Poetry Collection</h1>
2656<p>Poems sorted by %s to: %s</p>
2657<p>%s</p>
2658</center>
2659<table align="center"><tr><td>
2660<pre>
2661%s
2662
2663%s
2664
2665%s
2666</pre>
2667</td></tr></table>
2668</body>
2669</html>]]
2670
2671 return string.format(template,
2672 page_type_desc, starting_title, page_num, total_pages,
2673 page_type_desc, starting_title,
2674 download_links,
2675 header_nav,
2676 formatted_content,
2677 footer_nav)
2678end
2679-- }}}
2680
2681-- {{{ function M.generate_all_paginated_pages_for_poem
2682-- Generates all paginated pages for a single poem's similarity or diversity ordering
2683-- starting_poem: the anchor poem object
2684-- sorted_poems: full sorted list of all poems
2685-- page_type: "similar" or "different"
2686-- starting_poem_id: the anchor poem's ID
2687-- output_dir: base output directory
2688-- pages_to_generate: optional - which pages to generate (nil = use config limits, or {1,2,3} for specific pages)
2689-- chrono_mapping: poem_index -> {page_number, total_pages, ...}; see Issue 10-036
2690-- chrono_paginated: whether the chronological view was split into numbered pages
2691-- Returns: table with generated file paths and stats
2692-- Updated for Issue 8-020: Respects max_pages_per_poem storage constraint
2693-- Issue 10-036 (regression): these last two parameters did not exist, so the
2694-- sequential path -- the ONLY path that runs now that effil is gone -- had no way
2695-- to tell the formatter which chronological page a poem sits on. Every link fell
2696-- to the "01" guess. They are threaded, not read from config, because
2697-- --chrono-per-page enables pagination at runtime without touching the config.
2698function M.generate_all_paginated_pages_for_poem(starting_poem, sorted_poems, page_type, starting_poem_id, output_dir, pages_to_generate, chrono_mapping, chrono_paginated)
2699 -- Ensure pagination config is loaded
2700 load_pagination_config()
2701
2702 local total_poems = #sorted_poems
2703 local total_pages_possible = calculate_page_count(total_poems)
2704
2705 -- Apply max_pages_per_poem limit (Issue 8-020: 45GB storage constraint)
2706 local max_pages = PAGINATION_CONFIG.max_pages_per_poem
2707 local total_pages = math.min(total_pages_possible, max_pages)
2708
2709 local results = {
2710 files_generated = {},
2711 total_pages = total_pages,
2712 total_pages_possible = total_pages_possible, -- Before storage limit
2713 poems_per_page = PAGINATION_CONFIG.poems_per_page,
2714 poem_id = starting_poem_id,
2715 storage_limited = (total_pages < total_pages_possible) -- Indicates if pages were capped
2716 }
2717
2718 -- Determine which pages to generate
2719 local pages = pages_to_generate
2720 if not pages then
2721 -- Generate pages 1 through max_pages (respecting storage limit)
2722 pages = {}
2723 for i = 1, total_pages do
2724 table.insert(pages, i)
2725 end
2726 end
2727
2728 -- Ensure output directory exists
2729 local page_dir = output_dir .. "/" .. page_type
2730 os.execute("mkdir -p " .. page_dir)
2731
2732 -- Generate each requested page (respecting max_pages limit)
2733 for _, page_num in ipairs(pages) do
2734 if page_num <= total_pages then
2735 local html = M.generate_paginated_poem_page_html(
2736 starting_poem, sorted_poems, page_type, starting_poem_id,
2737 page_num, total_pages, total_poems, -- Pass total_poems for storage context
2738 chrono_mapping, chrono_paginated) -- Issue 10-036: aim the chronological links at real pages
2739
2740 if html then
2741 local filename = generate_page_filename(starting_poem_id, page_num, page_type)
2742 local filepath = output_dir .. "/" .. filename
2743
2744 if utils.write_file(filepath, html) then
2745 table.insert(results.files_generated, filepath)
2746 end
2747 end
2748 end
2749 end
2750
2751 return results
2752end
2753-- }}}
2754
2755-- {{{ function M.get_pagination_config
2756-- Exposes pagination configuration for external scripts
2757-- Returns: PAGINATION_CONFIG table
2758function M.get_pagination_config()
2759 load_pagination_config()
2760 return PAGINATION_CONFIG
2761end
2762-- }}}
2763
2764-- {{{ function M.get_storage_config
2765-- Exposes storage configuration for external scripts (Issue 8-020)
2766-- Returns: STORAGE_CONFIG table
2767function M.get_storage_config()
2768 load_pagination_config() -- This also loads storage config
2769 return STORAGE_CONFIG
2770end
2771-- }}}
2772
2773-- {{{ function M.calculate_page_count
2774-- Exposes page count calculation for external scripts
2775-- Returns: number of pages needed for given poem count
2776function M.calculate_page_count(total_poems)
2777 load_pagination_config()
2778 return calculate_page_count(total_poems)
2779end
2780-- }}}
2781
2782-- {{{ local function generate_chronological_page_navigation
2783-- Issue 8-039: Files now in chronological/ subdirectory, use simpler relative paths
2784local function generate_chronological_page_navigation(current_page, total_pages)
2785 -- Generate pagination navigation for chronological pages
2786 -- Format: [« First] [‹ Prev] Page X of Y [Next ›] [Last »]
2787 -- Issue 8-039: Using relative paths within chronological/ directory (01.html, not chronological-01.html)
2788 if total_pages <= 1 then
2789 return ""
2790 end
2791
2792 local nav_parts = {}
2793
2794 -- First page link
2795 if current_page > 1 then
2796 table.insert(nav_parts, "<a href='01.html'>« First</a>")
2797 else
2798 table.insert(nav_parts, "« First")
2799 end
2800
2801 -- Previous page link
2802 if current_page > 1 then
2803 table.insert(nav_parts, string.format("<a href='%02d.html'>‹ Prev</a>", current_page - 1))
2804 else
2805 table.insert(nav_parts, "‹ Prev")
2806 end
2807
2808 -- Current page indicator
2809 table.insert(nav_parts, string.format("Page %d of %d", current_page, total_pages))
2810
2811 -- Next page link
2812 if current_page < total_pages then
2813 table.insert(nav_parts, string.format("<a href='%02d.html'>Next ›</a>", current_page + 1))
2814 else
2815 table.insert(nav_parts, "Next ›")
2816 end
2817
2818 -- Last page link
2819 if current_page < total_pages then
2820 table.insert(nav_parts, string.format("<a href='%02d.html'>Last »</a>", total_pages))
2821 else
2822 table.insert(nav_parts, "Last »")
2823 end
2824
2825 -- Issue 8-052: Use Unicode box-drawing vertical for consistent HTML output
2826 return table.concat(nav_parts, " │ ")
2827end
2828-- }}}
2829
2830-- {{{ function M.generate_chronological_index_with_navigation
2831-- Issue 9-003: chrono_per_page parameter allows CLI override of poems per page
2832function M.generate_chronological_index_with_navigation(poems_data, output_dir, chrono_per_page)
2833 -- Load pagination config for chronological settings (Issue 9-003 Fix F)
2834 load_pagination_config()
2835
2836 local chronological_paginated = PAGINATION_CONFIG.chronological_paginated or false
2837 local poems_per_page = PAGINATION_CONFIG.chronological_poems_per_page or 500
2838
2839 -- Apply CLI override if provided. Pagination is enabled either by config
2840 -- or by the operator supplying --chrono-per-page.
2841 if chrono_per_page and type(chrono_per_page) == "number" and chrono_per_page > 0 then
2842 poems_per_page = chrono_per_page
2843 chronological_paginated = true
2844 end
2845
2846 utils.log_info(string.format("Chronological pagination: %d poems/page", poems_per_page))
2847
2848 -- Sort poems chronologically (by actual post dates)
2849 local sorted_poems_with_timestamps = sort_poems_chronologically_by_dates(poems_data)
2850 local total_poems = #sorted_poems_with_timestamps
2851
2852 -- Issue 8-045: Calculate timeline bounds for time-based progress bars
2853 local first_timestamp = sorted_poems_with_timestamps[1] and sorted_poems_with_timestamps[1].timestamp or 0
2854 local last_timestamp = sorted_poems_with_timestamps[total_poems] and sorted_poems_with_timestamps[total_poems].timestamp or 0
2855 local timeline_span = last_timestamp - first_timestamp
2856 if timeline_span <= 0 then timeline_span = 1 end -- Avoid division by zero
2857
2858 -- Calculate pagination
2859 local total_pages = chronological_paginated and math.ceil(total_poems / poems_per_page) or 1
2860 if total_pages < 1 then total_pages = 1 end
2861
2862 utils.log_info(string.format("Generating chronological HTML for %d poems (%d pages, %d poems/page)...",
2863 total_poems, total_pages, chronological_paginated and poems_per_page or total_poems))
2864 local generation_start = os.time()
2865
2866 -- Load poem colors for progress bars
2867 local poem_colors = load_poem_colors()
2868
2869 os.execute("mkdir -p " .. output_dir)
2870
2871 local files_written = {}
2872
2873 for page_num = 1, total_pages do
2874 -- Calculate poem range for this page
2875 local start_idx = (page_num - 1) * poems_per_page + 1
2876 local end_idx = chronological_paginated and math.min(page_num * poems_per_page, total_poems) or total_poems
2877
2878 -- Generate page navigation
2879 local page_nav = generate_chronological_page_navigation(page_num, total_pages)
2880 local page_nav_html = page_nav ~= "" and string.format("<p>%s</p>", page_nav) or ""
2881
2882 -- Template with optional pagination navigation
2883 -- Issue 9-003 Fix: Use centered table for block centering with left-aligned text inside
2884 -- Issue 16-010: Added FONT_STYLE for Hack Nerd Font font-stack
2885 local template
2886 if chronological_paginated and total_pages > 1 then
2887 template = string.format([[<!DOCTYPE html>
2888<html>
2889<head>
2890<meta charset="UTF-8">
2891<title>Poetry Collection - Chronological Order (Page %d of %d)</title>
2892%s</head>
2893<body bgcolor="#000000" text="#FFFFFF" link="#6699FF" vlink="#9966FF">
2894<center>
2895<h1>Poetry Collection</h1>
2896<p>Poems in true chronological order by post date</p>
2897%s
2898<p><a href="../wordcloud.html">Menu</a></p>
2899</center>
2900<table align="center"><tr><td>
2901<pre>
2902%%s
2903</pre>
2904</td></tr></table>
2905<center>%s</center>
2906</body>
2907</html>]], page_num, total_pages, FONT_STYLE, page_nav_html, page_nav_html)
2908 else
2909 template = [[<!DOCTYPE html>
2910<html>
2911<head>
2912<meta charset="UTF-8">
2913<title>Poetry Collection - Chronological Order</title>
2914]] .. FONT_STYLE .. [[</head>
2915<body bgcolor="#000000" text="#FFFFFF" link="#6699FF" vlink="#9966FF">
2916<center>
2917<h1>Poetry Collection</h1>
2918<p>All poems in true chronological order by post date</p>
2919<p><a href="../wordcloud.html">Menu</a></p>
2920</center>
2921<table align="center"><tr><td>
2922<pre>
2923%s
2924</pre>
2925</td></tr></table>
2926</body>
2927</html>]]
2928 end
2929
2930 -- Generate content for this page
2931 local content = ""
2932 for i = start_idx, end_idx do
2933 local poem_info = sorted_poems_with_timestamps[i]
2934 local poem = poem_info.poem
2935 local poem_id = poem.poem_index
2936
2937 -- Progress output every 100 poems
2938 if i % 100 == 0 or i == total_poems then
2939 local elapsed = os.time() - generation_start
2940 local rate = i / math.max(elapsed, 1)
2941 local eta = (total_poems - i) / math.max(rate, 1)
2942 local progress_msg = string.format("\r Processing poem %d/%d (%.1f%%) - %.1f poems/sec, ETA: %ds",
2943 i, total_poems, (i / total_poems) * 100, rate, eta)
2944 io.write(progress_msg .. string.rep(" ", math.max(0, 80 - #progress_msg)))
2945 io.flush()
2946 end
2947
2948 -- Issue 8-045: Calculate chronological progress based on actual timestamp
2949 -- This shows temporal position in the author's timeline, not just poem count
2950 local poem_timestamp = poem_info.timestamp or first_timestamp
2951 local timeline_progress = ((poem_timestamp - first_timestamp) / timeline_span) * 100
2952 local progress_info = {
2953 poem_id = poem_id,
2954 total_poems = total_poems,
2955 percentage = timeline_progress, -- Issue 8-045: time-based, not position-based
2956 position = i,
2957 temporal_index = i
2958 }
2959
2960 local poem_color_data = poem_colors[poem_id]
2961 local semantic_color = poem_color_data and poem_color_data.color or "gray"
2962 local is_golden = is_golden_poem(poem)
2963 local is_boost = is_boost_poem(poem) -- Issue 10-040: Check for boosts
2964 local anchor_id = get_poem_anchor_id(poem)
2965 local poem_index = poem.poem_index or 0
2966
2967 -- Add HTML anchor
2968 content = content .. string.format('<span id="%s"></span>', anchor_id)
2969 content = content .. string.format(" -> file: %s\n", get_poem_display_filename(poem))
2970
2971 -- Navigation links (absolute paths for consistency)
2972 -- Issue 9-003: Use absolute file:// paths - helper script converts to production URLs
2973 local base_path = ".."
2974 local similar_link = string.format("<a href='%s/similar/%04d-01.html'>similar</a>", base_path, poem_index)
2975 local different_link = string.format("<a href='%s/different/%04d-01.html'>different</a>", base_path, poem_index)
2976 local chronological_link = nil -- Issue 9-003 Fix C: No chronological link on chronological pages
2977
2978 -- Issue 10-040: Apply boost formatting consistently on chronological pages
2979 -- Uses same boost box styling as similar/different pages
2980 if is_boost then
2981 -- Escape HTML and apply markdown to content
2982 local text = escape_html(poem.content or "")
2983
2984 -- Issue 10-037: Defensive fallback for blank boost content
2985 if text == "" or text:match("^%s*$") then
2986 local original_uri = poem.metadata and poem.metadata.original_uri
2987 if original_uri then
2988 text = "External post: " .. escape_html(original_uri)
2989 else
2990 text = "(Boost content unavailable)"
2991 end
2992 end
2993
2994 -- Issue 10-039: Make external boost URLs clickable
2995 local external_pattern = "^External post: (https?://[^%s]+)$"
2996 local external_url = text:match(external_pattern)
2997 if external_url then
2998 -- Wrap the URL across box lines instead of overflowing.
2999 text = text_formatter.wrap_external_url("External post: ", external_url, boost_bars.CONTENT_WIDTH)
3000 else
3001 -- Issue 10-041: Wrap long embedded content to fit boost box
3002 local BOOST_CONTENT_WIDTH = boost_bars.CONTENT_WIDTH
3003 local wrapped_lines = {}
3004 for line in (text .. "\n"):gmatch("(.-)\n") do
3005 local wrapped = text_formatter.wrap_preserving_indent(line, BOOST_CONTENT_WIDTH)
3006 for _, wrapped_line in ipairs(wrapped) do
3007 table.insert(wrapped_lines, wrapped_line)
3008 end
3009 end
3010 text = table.concat(wrapped_lines, "\n")
3011 end
3012
3013 text = apply_markdown_formatting(text)
3014
3015 -- Calculate progress as decimal (0-1) for boost functions
3016 local progress_decimal = progress_info.percentage / 100
3017
3018 -- Apply complete boost formatting (includes all frame elements)
3019 local boost_formatted = apply_boost_poem_formatting(
3020 text, progress_decimal, similar_link, different_link, chronological_link
3021 )
3022 content = content .. boost_formatted .. "\n"
3023
3024 -- Render attached images after boost frame
3025 if poem.attachments then
3026 content = content .. render_attachment_images(poem.attachments)
3027 end
3028 else
3029 -- Standard formatting for golden and regular poems
3030 -- Generate top progress bar
3031 local top_dashes = generate_progress_dashes(progress_info, semantic_color, is_golden, "top")
3032 content = content .. string.format('<span %s>%s</span>\n',
3033 top_dashes.accessibility,
3034 top_dashes.visual)
3035
3036 -- Add poem content
3037 local hex_color = COLOR_CONFIG[semantic_color] or COLOR_CONFIG["gray"]
3038 local formatted_content = format_content_with_warnings(
3039 poem.content or "", poem.category, poem,
3040 is_golden and similar_link or nil,
3041 is_golden and different_link or nil,
3042 is_golden and chronological_link or nil,
3043 is_golden and hex_color or nil
3044 )
3045 content = content .. formatted_content
3046
3047 -- Add images if present
3048 -- Issue 9-010: Images stay with their original post only (no associated_images rendering)
3049 if poem.attachments and #poem.attachments > 0 then
3050 content = content .. render_attachment_images(poem.attachments)
3051 end
3052
3053 -- Add navigation box for regular poems
3054 if not is_golden then
3055 -- Issue 8-035: Calculate progress_chars for nav box colorization
3056 local total_chars = LAYOUT.REGULAR_POEM_WIDTH
3057 local progress_chars = math.floor((progress_info.percentage / 100) * total_chars)
3058
3059 content = content .. "\n"
3060 content = content .. generate_regular_corner_box_top(progress_chars, hex_color) .. "\n"
3061 content = content .. generate_regular_corner_box_nav_line(similar_link, different_link, chronological_link, progress_chars, hex_color) .. "\n"
3062 else
3063 content = content .. "\n"
3064 end
3065 end
3066
3067 -- Generate bottom progress bar (skip for boosts - they have their own bottom border)
3068 if not is_boost then
3069 local bottom_dashes = generate_progress_dashes(progress_info, semantic_color, is_golden, "bottom", true)
3070 content = content .. string.format('<span %s>%s</span>\n\n',
3071 bottom_dashes.accessibility,
3072 bottom_dashes.visual)
3073 else
3074 content = content .. "\n" -- Just add spacing between poems
3075 end
3076 end
3077
3078 -- Write page file
3079 -- Issue 8-039: Files now in chronological/ subdirectory
3080 local final_html = string.format(template, content)
3081 local chrono_dir = output_dir .. "/chronological"
3082 os.execute(string.format('mkdir -p "%s"', chrono_dir))
3083
3084 local output_file
3085 if chronological_paginated and total_pages > 1 then
3086 -- Paginated: chronological/01.html, chronological/02.html, etc.
3087 output_file = string.format("%s/%02d.html", chrono_dir, page_num)
3088 else
3089 -- Single page: chronological/index.html (for clean URL)
3090 output_file = chrono_dir .. "/index.html"
3091 end
3092
3093 local success = utils.write_file(output_file, final_html)
3094 if success then
3095 table.insert(files_written, output_file)
3096 else
3097 utils.log_error("Failed to write: " .. output_file)
3098 end
3099 end
3100
3101 io.write("\n")
3102 local total_elapsed = os.time() - generation_start
3103 utils.log_info(string.format("Chronological HTML generation complete: %d poems, %d pages in %d seconds",
3104 total_poems, total_pages, total_elapsed))
3105
3106 -- Issue 8-039: For paginated chronological, create index.html redirect within the subdirectory
3107 if chronological_paginated and total_pages > 1 then
3108 local chrono_dir = output_dir .. "/chronological"
3109 local redirect_html = [[<!DOCTYPE html>
3110<html>
3111<head>
3112<meta charset="UTF-8">
3113<meta http-equiv="refresh" content="0;url=01.html">
3114<title>Redirecting...</title>
3115</head>
3116<body bgcolor="#000000" text="#FFFFFF" link="#6699FF" vlink="#9966FF">
3117<p>Redirecting to <a href="01.html">01.html</a>...</p>
3118</body>
3119</html>]]
3120 utils.write_file(chrono_dir .. "/index.html", redirect_html)
3121 utils.log_info("✓ chronological/index.html created (redirect to 01.html)")
3122 end
3123
3124 return files_written[1]
3125end
3126-- }}}
3127
3128-- {{{ function explore_page_shell()
3129-- Shared HTML shell for the explore pages: black background, monospace, the
3130-- corrected centered-<pre> layout. Returns the full document for a title +
3131-- heading + pre-formatted body.
3132local function explore_page_shell(title, heading, body)
3133 return string.format([[<!DOCTYPE html>
3134<html>
3135<head>
3136<meta charset="UTF-8">
3137<title>%s</title>
3138]] .. FONT_STYLE .. [[</head>
3139<body bgcolor="#000000" text="#FFFFFF" link="#6699FF" vlink="#9966FF">
3140<center>
3141<h1>%s</h1>
3142</center>
3143<table align="center"><tr><td>
3144<pre>
3145%s
3146</pre>
3147</td></tr></table>
3148</body>
3149</html>]], title, heading, body)
3150end
3151-- }}}
3152
3153-- {{{ function corpus_stats()
3154-- Gather the live numbers both explore pages render from, so nothing is
3155-- hard-coded (stale figures are worse than no figures). Reads only poems_data
3156-- (the small 12MB file) -- never the 662MB similarity matrix -- so it is cheap.
3157local function corpus_stats(poems_data)
3158 local poems = (poems_data and poems_data.poems) or {}
3159 local stats = {
3160 total = #poems,
3161 sources = {}, -- category -> count
3162 source_order = {}, -- source names, most-poems first
3163 image_only = 0,
3164 min_date = nil, max_date = nil,
3165 per_year = {}, year_order = {},
3166 length_hist = {}, length_labels = {}, -- length-distribution buckets
3167 }
3168 -- Length buckets (characters). The last bucket is open-ended.
3169 local edges = {0, 100, 250, 500, 1000, 2000}
3170 for i = 1, #edges do
3171 stats.length_hist[i] = 0
3172 if i < #edges then
3173 stats.length_labels[i] = string.format("%d-%d", edges[i], edges[i + 1] - 1)
3174 else
3175 stats.length_labels[i] = string.format("%d+", edges[i])
3176 end
3177 end
3178 for _, p in ipairs(poems) do
3179 local cat = p.category or "unknown"
3180 stats.sources[cat] = (stats.sources[cat] or 0) + 1
3181 if p.is_image_only then stats.image_only = stats.image_only + 1 end
3182 local d = p.creation_date
3183 if d and d ~= "" then
3184 if not stats.min_date or d < stats.min_date then stats.min_date = d end
3185 if not stats.max_date or d > stats.max_date then stats.max_date = d end
3186 local year = d:sub(1, 4)
3187 if year:match("^%d%d%d%d$") then
3188 stats.per_year[year] = (stats.per_year[year] or 0) + 1
3189 end
3190 end
3191 -- Place the poem in its length bucket (last edge is open-ended).
3192 local len = p.length or #(p.content or "")
3193 local bucket = #edges
3194 for i = 1, #edges - 1 do
3195 if len < edges[i + 1] then bucket = i; break end
3196 end
3197 stats.length_hist[bucket] = stats.length_hist[bucket] + 1
3198 end
3199 for cat in pairs(stats.sources) do stats.source_order[#stats.source_order + 1] = cat end
3200 table.sort(stats.source_order, function(a, b) return stats.sources[a] > stats.sources[b] end)
3201 for year in pairs(stats.per_year) do stats.year_order[#stats.year_order + 1] = year end
3202 table.sort(stats.year_order)
3203 return stats
3204end
3205-- }}}
3206
3207-- {{{ function ascii_bar_row()
3208-- One labelled monospace bar: "<label padded> | ████···· <count>". Fits the
3209-- site's no-JS, monospace aesthetic (same idiom as the poem progress bars).
3210local function ascii_bar_row(label, count, max_count, bar_width, label_width)
3211 local filled = (max_count > 0) and math.floor((count / max_count) * bar_width + 0.5) or 0
3212 if filled > bar_width then filled = bar_width end
3213 local bar = string.rep("█", filled) .. string.rep("·", bar_width - filled)
3214 return string.format("%-" .. label_width .. "s | %s %d", label, bar, count)
3215end
3216-- }}}
3217
3218-- {{{ function M.generate_simple_discovery_instructions
3219-- Back-compat shim: callers that pass only output_dir still work (boosts/golden
3220-- counts simply won't appear without the corpus). Prefer passing poems_data.
3221function M.generate_simple_discovery_instructions(output_dir, poems_data)
3222 M.generate_explore_page(output_dir, poems_data)
3223 M.generate_explore_math_page(output_dir, poems_data)
3224 return output_dir .. "/explore.html"
3225end
3226-- }}}
3227
3228-- {{{ function M.generate_explore_page()
3229-- explore.html -- the welcome / map: orientation + live corpus stats + every
3230-- navigation mode + links to the deeper-math page and (placeholder) the source
3231-- browser (Issue 10-052). Data/view split: corpus_stats() computes, this renders.
3232function M.generate_explore_page(output_dir, poems_data)
3233 local s = corpus_stats(poems_data)
3234
3235 -- The per-source list is a LOOP over the corpus, so it stays rendered here
3236 -- and is handed to the template as one ready-made block (Issue 11-005: prose
3237 -- and scalars live in the editable file; loops stay in code).
3238 local source_rows = {}
3239 for _, cat in ipairs(s.source_order) do
3240 source_rows[#source_rows + 1] = string.format(" %-22s %d", cat, s.sources[cat])
3241 end
3242
3243 -- The scalar facts that only make sense when they exist use page_template.OMIT,
3244 -- which drops the whole template line -- matching the old "only add this line
3245 -- when there is a date / an image-only count" conditionals, with no blank gap.
3246 local values = {
3247 TOTAL_POEMS = s.total,
3248 SOURCE_COUNT = #s.source_order,
3249 MIN_DATE = (s.min_date and s.max_date) and s.min_date:sub(1, 10) or page_template.OMIT,
3250 MAX_DATE = (s.min_date and s.max_date) and s.max_date:sub(1, 10) or page_template.OMIT,
3251 IMAGE_ONLY_COUNT = (s.image_only > 0) and s.image_only or page_template.OMIT,
3252 SOURCE_LIST = table.concat(source_rows, "\n"),
3253 }
3254
3255 local template_path = DIR .. "/input/pages/explore.txt"
3256 local body, err = page_template.render_file(template_path, values)
3257 -- A broken template (typo'd marker, missing file) is a real error worth
3258 -- halting on -- a half-filled page is worse than a loud failure (no fallbacks).
3259 if not body then error("generate_explore_page: " .. tostring(err)) end
3260 -- The template file ends with a newline; the page shell adds its own, so trim
3261 -- trailing newlines to keep the centered <pre> block from gaining a blank tail.
3262 body = body:gsub("\n+$", "")
3263
3264 local html = explore_page_shell(
3265 "Poetry Collection - Explore", "Poetry Collection - Explore", body)
3266 local output_file = output_dir .. "/explore.html"
3267 return utils.write_file(output_file, html) and output_file or nil
3268end
3269-- }}}
3270
3271-- {{{ function M.generate_explore_math_page()
3272-- explore-2.html -- the deeper math: how the semantic engine works, explained
3273-- honestly, with REAL corpus-shape charts (per-source, length, over-time) drawn
3274-- as monospace bars. Similarity-distribution charts need the 662MB matrix that
3275-- is deliberately not loaded here, so they are noted as a future addition.
3276function M.generate_explore_math_page(output_dir, poems_data)
3277 local s = corpus_stats(poems_data)
3278 local BAR = 40
3279
3280 -- Each histogram is a LOOP over the corpus, so they stay rendered here and are
3281 -- handed to the template as ready-made blocks (Issue 11-005). ascii_bar_row
3282 -- draws one labelled monospace bar.
3283
3284 -- Poems-per-source bars.
3285 local max_src = 0
3286 for _, c in ipairs(s.source_order) do if s.sources[c] > max_src then max_src = s.sources[c] end end
3287 local source_bars = {}
3288 for _, cat in ipairs(s.source_order) do
3289 source_bars[#source_bars + 1] = " " .. ascii_bar_row(cat, s.sources[cat], max_src, BAR, 20)
3290 end
3291
3292 -- Poem-length bars.
3293 local max_len_bucket = 0
3294 for _, v in ipairs(s.length_hist) do if v > max_len_bucket then max_len_bucket = v end end
3295 local length_bars = {}
3296 for i, label in ipairs(s.length_labels) do
3297 length_bars[#length_bars + 1] = " " .. ascii_bar_row(label, s.length_hist[i], max_len_bucket, BAR, 20)
3298 end
3299
3300 -- Poems-per-year is a whole conditional section (blank line + heading + bars).
3301 -- When the corpus has no dated poems it becomes OMIT, dropping the section's
3302 -- template line entirely -- the same guard the inline version used.
3303 local year_section
3304 if #s.year_order > 0 then
3305 local year_lines = { "", " Poems per year:" }
3306 local max_year = 0
3307 for _, y in ipairs(s.year_order) do if s.per_year[y] > max_year then max_year = s.per_year[y] end end
3308 for _, y in ipairs(s.year_order) do
3309 year_lines[#year_lines + 1] = " " .. ascii_bar_row(y, s.per_year[y], max_year, BAR, 20)
3310 end
3311 year_section = table.concat(year_lines, "\n")
3312 else
3313 year_section = page_template.OMIT
3314 end
3315
3316 -- The embedding-model name comes from the live inference config rather than a
3317 -- baked-in string, so it can never drift from the model the pipeline actually
3318 -- used (per the "reference a source, don't hard-code figures" convention).
3319 local values = {
3320 EMBEDDING_MODEL = inference_config.get_selected_model(),
3321 TOTAL_POEMS = s.total,
3322 SOURCE_BARS = table.concat(source_bars, "\n"),
3323 LENGTH_BARS = table.concat(length_bars, "\n"),
3324 YEAR_SECTION = year_section,
3325 }
3326
3327 local template_path = DIR .. "/input/pages/explore-math.txt"
3328 local body, err = page_template.render_file(template_path, values)
3329 if not body then error("generate_explore_math_page: " .. tostring(err)) end
3330 body = body:gsub("\n+$", "")
3331
3332 local html = explore_page_shell(
3333 "Poetry Collection - The Math", "How the Similarity Works", body)
3334 local output_file = output_dir .. "/explore-2.html"
3335 return utils.write_file(output_file, html) and output_file or nil
3336end
3337-- }}}
3338
3339-- {{{ function generate_txt_file_header
3340local function generate_txt_file_header(title, total_poems)
3341 -- Generate a consistent header for TXT export files
3342 -- Matches the compiled.txt aesthetic with 80-character width
3343 local separator = string.rep("=", 80)
3344 local header = separator .. "\n"
3345
3346 -- Center the title
3347 local padding = math.floor((80 - #title) / 2)
3348 header = header .. string.rep(" ", padding) .. title .. "\n"
3349
3350 header = header .. separator .. "\n"
3351 header = header .. string.format("Total poems: %d\n", total_poems)
3352 header = header .. string.format("Generated: %s\n", os.date("%Y-%m-%d %H:%M:%S"))
3353 header = header .. separator .. "\n\n"
3354
3355 return header
3356end
3357-- }}}
3358
3359-- {{{ function generate_similarity_txt_file
3360function generate_similarity_txt_file(starting_poem, sorted_poems, output_file)
3361 -- Generate TXT export for similarity-sorted poems
3362 -- Includes file header with metadata and all poems formatted at 80-char width
3363 local title = string.format("POEMS SORTED BY SIMILARITY TO POEM %s", starting_poem.id or "?")
3364 local header = generate_txt_file_header(title, #sorted_poems + 1)
3365 local poems_content = format_all_poems_80_width(starting_poem, sorted_poems)
3366 local content = header .. poems_content
3367 return utils.write_file(output_file, content) and output_file or nil
3368end
3369-- }}}
3370
3371-- {{{ function generate_similarity_html_archive
3372-- Issue 10-036: Added chrono_mapping for correct paginated chronological links
3373function generate_similarity_html_archive(starting_poem, sorted_poems, output_file, chrono_mapping, chrono_paginated)
3374 -- Generate HTML archive for similarity-sorted poems (full corpus with images)
3375 -- Unlike paginated pages, this is a single file with ALL poems
3376 -- Use poem_index (globally unique) for consistency
3377 local html = M.generate_flat_poem_list_html(starting_poem, sorted_poems, "similar", starting_poem.poem_index, chrono_mapping, chrono_paginated)
3378 return utils.write_file(output_file, html) and output_file or nil
3379end
3380-- }}}
3381
3382-- {{{ function generate_diversity_txt_file
3383function generate_diversity_txt_file(starting_poem, sorted_poems, output_file)
3384 -- Generate TXT export for diversity-sorted poems
3385 -- Includes file header with metadata and all poems formatted at 80-char width
3386 local title = string.format("POEMS SORTED BY DIVERSITY FROM POEM %s", starting_poem.poem_index or "?")
3387 local header = generate_txt_file_header(title, #sorted_poems + 1)
3388 local poems_content = format_all_poems_80_width(starting_poem, sorted_poems)
3389 local content = header .. poems_content
3390 return utils.write_file(output_file, content) and output_file or nil
3391end
3392-- }}}
3393
3394-- {{{ function generate_diversity_html_archive
3395-- Issue 10-036: Added chrono_mapping for correct paginated chronological links
3396function generate_diversity_html_archive(starting_poem, sorted_poems, output_file, chrono_mapping, chrono_paginated)
3397 -- Generate HTML archive for diversity-sorted poems (full corpus with images)
3398 -- Unlike paginated pages, this is a single file with ALL poems
3399 -- Use poem_index (globally unique) for consistency
3400 local html = M.generate_flat_poem_list_html(starting_poem, sorted_poems, "different", starting_poem.poem_index, chrono_mapping, chrono_paginated)
3401 return utils.write_file(output_file, html) and output_file or nil
3402end
3403-- }}}
3404
3405-- {{{ function M.generate_chronological_txt_file
3406function M.generate_chronological_txt_file(poems_data, output_file)
3407 -- Generate TXT export for all poems in chronological order
3408 -- Uses actual post dates for sorting (not poem IDs)
3409 -- Includes file header with metadata and all poems formatted at 80-char width
3410
3411 -- Sort poems chronologically by actual post dates
3412 local sorted_poems = sort_poems_chronologically_by_dates(poems_data)
3413 local total_poems = #sorted_poems
3414
3415 -- Generate header
3416 local title = "POEMS IN CHRONOLOGICAL ORDER"
3417 local header = generate_txt_file_header(title, total_poems)
3418
3419 -- Generate content for each poem
3420 local content = header
3421 for i, poem_info in ipairs(sorted_poems) do
3422 content = content .. format_single_poem_80_width(poem_info.poem)
3423 content = content .. "\n\n"
3424 end
3425
3426 return utils.write_file(output_file, content) and output_file or nil
3427end
3428-- }}}
3429
3430-- {{{ function M.generate_complete_flat_html_collection
3431-- Generates all similarity and diversity pages for the entire corpus
3432-- poems_data: full poems dataset
3433-- similarity_data: similarity matrix
3434-- embeddings_data: poem embeddings (for diversity calculation)
3435-- output_dir: base output directory
3436-- pages_spec: (optional) --pages flag value: nil/"default", "all", "1", "1-10" (Phase D: Issue 8-012)
3437-- poems_per_page: (optional) CLI override for poems per page (Issue 8-022)
3438-- num_threads: (optional) number of parallel threads (default: 1 = single-threaded)
3439-- chrono_per_page: (optional) CLI override for chronological poems per page (Issue 9-003)
3440function M.generate_complete_flat_html_collection(poems_data, similarity_data, embeddings_data, output_dir, pages_spec, poems_per_page, num_threads, chrono_per_page)
3441 -- Load diversity cache for fast HTML generation (Issue: diversity generation taking 42+ hours)
3442 -- Cache provides instant lookup of pre-computed GPU diversity sequences
3443 load_diversity_cache()
3444
3445 -- Load similarity rankings cache for fast HTML generation
3446 -- Cache provides instant lookup of pre-sorted similarity rankings (no O(n log n) sorting per poem)
3447 load_similarity_rankings_cache()
3448
3449 -- Load pagination config first
3450 load_pagination_config()
3451
3452 -- Issue 8-048: Flatten media files to output/media/ for easier deployment
3453 -- Must happen before HTML generation so paths resolve correctly
3454 flatten_media_files(output_dir)
3455
3456 -- Apply CLI override if provided. The honest summary below is logged
3457 -- whether or not an override was supplied — what the operator wants to
3458 -- see is "what value am I actually using," not "which knob set it."
3459 if poems_per_page and type(poems_per_page) == "number" and poems_per_page > 0 then
3460 PAGINATION_CONFIG.poems_per_page = poems_per_page
3461 end
3462
3463 -- Issue 10-057 follow-up: the storage ceiling on pages-per-poem is MEASURED from
3464 -- the budget and the last build's actual page sizes, not frozen in config (the old
3465 -- literal 15 would have shipped ~66GB into a 45GB quota). Self-corrects each build.
3466 PAGINATION_CONFIG.max_pages_per_poem =
3467 compute_storage_max_pages(output_dir, #(poems_data.poems or {}))
3468
3469 -- Issue 10-057: both neighbour caches may be capped to the top-K poems per poem
3470 -- (each stamps the K it was built with). If this run asks for more pages than that
3471 -- K can fill, a poem would silently get fewer pages than --pages requested. Fail
3472 -- loudly with the exact regen command instead of under-generating. A stamp of 0
3473 -- (or no stamp -- an older, uncapped cache) means "keep all", always enough.
3474 do
3475 local per_page = PAGINATION_CONFIG.poems_per_page
3476 local pages
3477 if not pages_spec or pages_spec == "" or pages_spec == "default" then
3478 pages = PAGINATION_CONFIG.minimum_pages
3479 elseif pages_spec == "all" then
3480 pages = PAGINATION_CONFIG.max_pages_per_poem
3481 else
3482 pages = tonumber(pages_spec)
3483 or tonumber(tostring(pages_spec):match("(%d+)$"))
3484 or PAGINATION_CONFIG.minimum_pages
3485 end
3486 local needed_k = pages * per_page
3487 local function check_cache(cache, label, regen_flag)
3488 local meta = cache and cache.metadata
3489 local stored_k = meta and tonumber(meta.top_k) or 0
3490 if stored_k > 0 and stored_k < needed_k then
3491 error(string.format(
3492 "%s cache holds only top-%d per poem, but this run needs %d (%d page(s) "
3493 .. "x %d poems/page). Regenerate it for these settings: ./run.sh %s "
3494 .. "--pages %d --poems-per-page %d",
3495 label, stored_k, needed_k, pages, per_page, regen_flag, pages, per_page))
3496 end
3497 end
3498 check_cache(SIMILARITY_RANKINGS_CACHE, "Similarity", "--generate-similarity")
3499 check_cache(DIVERSITY_CACHE, "Diversity", "--generate-diversity")
3500 end
3501
3502 -- Count poems with valid poem_index (globally unique identifier)
3503 -- Note: poem.id is per-category and NOT unique across categories
3504 -- poem_index is the globally unique identifier used by embeddings/similarity
3505 local valid_poems = {}
3506 for i, poem in ipairs(poems_data.poems) do
3507 if poem.poem_index then
3508 valid_poems[poem.poem_index] = poem
3509 end
3510 end
3511
3512 local total_poems = 0
3513 for _ in pairs(valid_poems) do
3514 total_poems = total_poems + 1
3515 end
3516
3517 -- Parse pages specification (Phase D: Issue 8-012)
3518 local pages_config = parse_pages_specification(pages_spec, nil) -- total_pages not known yet
3519 local use_pagination = true -- Always use pagination now (Phase D)
3520
3521 -- Report the pages-per-poem THIS run will actually generate, not the storage
3522 -- ceiling. The orchestrator worker generates #pages_config.pages pages per poem
3523 -- (one page by default); the old banner printed the 15-page storage cap
3524 -- unconditionally, which read as "generating 15 pages" when it generates 1.
3525 -- The cap is still shown, clearly labelled as a ceiling, for context.
3526 local pages_per_poem = pages_config.is_all
3527 and PAGINATION_CONFIG.max_pages_per_poem
3528 or (pages_config.pages and #pages_config.pages or 1)
3529 utils.log_info(string.format(
3530 "Similarity/diversity pagination: %d poems/page, %d page(s) per poem (storage ceiling: %d pages, %dGB)",
3531 PAGINATION_CONFIG.poems_per_page,
3532 pages_per_poem,
3533 PAGINATION_CONFIG.max_pages_per_poem,
3534 STORAGE_CONFIG.limit_gb))
3535
3536 local results = {
3537 similarity_pages = {},
3538 diversity_pages = {},
3539 chronological_index = nil,
3540 txt_files = {},
3541 html_archives = {},
3542 instructions_page = nil
3543 }
3544
3545 -- Normalize num_threads
3546 num_threads = num_threads or 1
3547 if num_threads < 1 then num_threads = 1 end
3548
3549 -- Issue 10-057 (Piece 1, wired): clamp the worker count to what fits in free RAM
3550 -- before spawning. After the cache cap (Fix B) the fixed cost is small, so on a
3551 -- roomy machine this is a no-op -- but it is the guard rail that keeps a big corpus
3552 -- or a small box out of swap, and it logs the estimate either way.
3553 if num_threads > 1 then
3554 local budget = require("memory-budgeter")
3555 local model = inference_config.get_selected_model()
3556 -- fixed: the two neighbour caches the orchestrator holds resident (file size x
3557 -- ~2.5 for the parsed Lua table) plus the ~12MB poems data already in RAM.
3558 local sim_file = utils.embeddings_dir(model) .. "/similarity_rankings_cache.json"
3559 local div_file = utils.embeddings_dir_disk(model) .. "/diversity_cache.json"
3560 local fixed = ((budget.file_size_bytes(sim_file) or 0)
3561 + (budget.file_size_bytes(div_file) or 0)) * 2.5 + 12e6
3562 -- per worker: an effil Lua state (~25MB) plus the one page it builds at a time.
3563 num_threads = budget.fit_threads({
3564 pool = "ram", fixed = fixed, per_thread = 30e6,
3565 want = num_threads, label = "HTML",
3566 })
3567 end
3568
3569 -- Build ordered list of poem indices for batch distribution
3570 local poem_indices = {}
3571 for poem_index, _ in pairs(valid_poems) do
3572 table.insert(poem_indices, poem_index)
3573 end
3574 table.sort(poem_indices) -- Ensure consistent ordering across runs
3575
3576 -- Issue 10-036: Compute chrono_mapping before parallel/sequential split so both paths can use it
3577 local chronological_paginated = PAGINATION_CONFIG.chronological_paginated
3578 local chrono_poems_per_page_config = PAGINATION_CONFIG.chronological_poems_per_page or 500
3579 local effective_chrono_per_page = chrono_poems_per_page_config
3580 if chrono_per_page and type(chrono_per_page) == "number" and chrono_per_page > 0 then
3581 effective_chrono_per_page = chrono_per_page
3582 chronological_paginated = true
3583 end
3584 local chrono_mapping = compute_chronological_mapping(poems_data, chronological_paginated and effective_chrono_per_page or nil)
3585
3586 -- Check if parallel processing is available and requested
3587 local use_parallel = num_threads > 1 and has_threading and effil
3588
3589 if use_parallel then
3590 -- {{{ Parallel processing with effil threads (Issue 10-034: Orchestrator pattern)
3591 -- Main thread acts as cache server, sending 80KB work slices instead of workers loading 700MB
3592 utils.log_info(string.format("Using parallel processing with %d threads (orchestrator mode)", num_threads))
3593
3594 -- Issue 10-034: Create channels for orchestrator communication
3595 -- Workers request work → main sends slices → workers report completion
3596 local work_request_channel = effil.channel() -- Workers → Main: work requests + completions
3597 local work_response_channels = {} -- Main → Worker[i]: work slices or shutdown
3598 for t = 1, num_threads do
3599 work_response_channels[t] = effil.channel()
3600 end
3601
3602 -- Issue 10-034: Build work queue (all poem indices that need processing)
3603 local work_queue = {}
3604 for _, poem_index in ipairs(poem_indices) do
3605 table.insert(work_queue, poem_index)
3606 end
3607 local total_work = #work_queue
3608
3609 -- Issue 10-036: chrono_mapping is now computed before parallel/sequential split
3610 -- (see Issue 9-003 Fix D for original rationale)
3611
3612 -- Prepare shared config for threads (serializable data only)
3613 local thread_config = {
3614 dir = DIR,
3615 output_dir = output_dir,
3616 -- Issue 9-013: where the worker finds the image pseudo-poem manifest
3617 image_manifest_path = utils.embeddings_dir() .. "/image-manifest.json",
3618 pages_is_all = pages_config.is_all,
3619 pages_list = pages_config.pages,
3620 poems_per_page = PAGINATION_CONFIG.poems_per_page,
3621 generate_html_archives = PAGINATION_CONFIG.generate_html_archives,
3622 generate_txt_exports = PAGINATION_CONFIG.generate_txt_exports,
3623 -- Issue 9-003 Fix D: Full formatting data
3624 chrono_mapping = chrono_mapping,
3625 chrono_paginated = chronological_paginated,
3626 -- Issue 8-055: Pass layout constants to worker threads for consistency
3627 layout = {
3628 golden_poem_width = LAYOUT.GOLDEN_POEM_WIDTH or 84,
3629 regular_poem_width = LAYOUT.REGULAR_POEM_WIDTH or 82,
3630 text_content_width = LAYOUT.TEXT_CONTENT_WIDTH or 80,
3631 golden_left_junction = LAYOUT.GOLDEN_LEFT_JUNCTION_POS or 10,
3632 golden_right_junction = LAYOUT.GOLDEN_RIGHT_JUNCTION_POS or 71,
3633 regular_left_junction = LAYOUT.REGULAR_LEFT_JUNCTION_POS or 10,
3634 regular_right_junction = LAYOUT.REGULAR_RIGHT_JUNCTION_POS or 70
3635 }
3636 }
3637
3638 -- Create and launch worker threads
3639 local threads = {}
3640 local start_time = os.time()
3641
3642 -- Issue 10-034: Launch workers that request work from orchestrator
3643 for thread_id = 1, num_threads do
3644 -- effil.thread creates a new Lua state that runs the function
3645 -- Workers receive work slices via channels instead of loading full caches
3646 local thread_func = effil.thread(function(config, tid, request_channel, response_channel)
3647 -- Set up package paths in thread context
3648 package.path = config.dir .. "/libs/?.lua;" .. config.dir .. "/src/?.lua;" .. package.path
3649
3650 -- Load required modules in thread context
3651 local t_utils = require('utils')
3652 local t_dkjson = require('dkjson')
3653 -- Issue 8-056: Shared text formatting module for whitespace preservation
3654 local t_text_formatter = require('text-formatter')
3655 -- Shared box/bar drawing (canonical geometry) so this worker copy
3656 -- can't drift from the main thread's bars. See poem-bars.lua.
3657 local t_poem_bars = require('poem-bars')
3658 -- Issue 9-013: fold ranked image pseudo-poems into this worker's
3659 -- poem list so it draws them instead of dropping unknown indices.
3660 local t_image_render = require('image-render')
3661 t_utils.init_assets_root({config.dir})
3662
3663 -- Load data files (each thread loads independently - files are in disk cache)
3664 local poems_file = t_utils.asset_path("poems.json")
3665 local poems_data = t_utils.read_json_file(poems_file)
3666 if not poems_data then
3667 error("Thread " .. tid .. ": Failed to load poems.json")
3668 end
3669 t_image_render.inject_pseudo_poems(poems_data,
3670 t_image_render.load_manifest(config.image_manifest_path, t_utils.read_json_file))
3671
3672 -- Build poem lookup by poem_index
3673 local poem_lookup = {}
3674 for i, poem in ipairs(poems_data.poems) do
3675 if poem.poem_index then
3676 poem_lookup[poem.poem_index] = poem
3677 end
3678 end
3679
3680 -- Issue 10-034: Caches NOT loaded here - orchestrator sends work slices
3681 -- This saves 700MB RAM per worker thread
3682
3683 -- Load poem colors (small file: ~900KB, acceptable per-worker)
3684 local poem_colors_file = t_utils.embeddings_dir() .. "/poem_colors.json"
3685 local poem_colors_data = t_utils.read_json_file(poem_colors_file)
3686 local poem_colors = poem_colors_data and poem_colors_data.poem_colors or {}
3687
3688 -- Color config for progress bars
3689 local color_config = {
3690 red = "#dc3c3c", blue = "#3c78dc", green = "#3cb45a",
3691 purple = "#8c3cc8", orange = "#e68c3c", yellow = "#c8b428", gray = "#787878"
3692 }
3693
3694 -- Local helper: Get unique filename ID for poem
3695 local function get_unique_id(poem)
3696 local cat_prefix = (poem.category or "unknown"):sub(1, 1):lower()
3697 local id_num = poem.id or poem.poem_index or 0
3698 return string.format("%s-%04d", cat_prefix, id_num)
3699 end
3700
3701 -- {{{ Local helper: Get source path for poem identification in ranking headers
3702 -- Issue 8-036: Returns human-readable source path for each category
3703 local function get_source_path(poem)
3704 local category = poem.category or "unknown"
3705 if category == "notes" and poem.metadata and poem.metadata.source_file then
3706 -- Notes show original descriptive filename
3707 return "notes/" .. poem.metadata.source_file
3708 elseif category == "bluesky" then
3709 -- Bluesky uses # notation
3710 return "bluesky#" .. (poem.id or 0)
3711 elseif category == "fediverse" then
3712 -- Fediverse shows category/id
3713 return "fediverse/" .. (poem.id or 0)
3714 elseif category == "messages" then
3715 -- Messages shows category/id
3716 return "messages/" .. (poem.id or 0)
3717 else
3718 return category .. "/" .. (poem.id or poem.poem_index or 0)
3719 end
3720 end
3721 -- }}}
3722
3723 -- {{{ Local helper: Check if poem is golden (exactly 1024 chars when posted)
3724 -- Issue 8-044: Use pre-calculated metadata as single source of truth
3725 local function is_golden_poem(poem)
3726 if poem.metadata and poem.metadata.is_golden_poem then
3727 return true
3728 end
3729 return false
3730 end
3731 -- }}}
3732
3733 -- {{{ Local helper: Check if poem is a boost (Issue 8-057)
3734 local function is_boost_poem(poem)
3735 if poem.metadata and poem.metadata.is_boost then
3736 return true
3737 end
3738 return false
3739 end
3740 -- }}}
3741
3742 -- Issue 8-057: Boost color configuration for worker thread
3743 local BOOST_COLORS = {
3744 arrow = "#dc3c3c", -- Red: ◀═ and ─▶ arrows, [BOOST] label
3745 outer_frame = "#3c78dc", -- Blue: ╔═╗║╚═╝ outer frame
3746 inner_box = "#2aa198", -- Teal: ┌─┐│└─┘ inner content box
3747 content_text = "#c8b428" -- Yellow: boosted text content
3748 }
3749
3750 -- Same shared boost-frame module the main thread uses; require()
3751 -- reloads it fresh in this isolated worker state (only live
3752 -- closures can't cross states, plain modules reload from disk).
3753 local t_boost_bars = require('boost-bars')
3754 t_boost_bars.configure(BOOST_COLORS)
3755
3756 -- Local helper: Build poem lookup by poem_index for ranking conversion
3757 local function build_poem_by_index()
3758 local lookup = {}
3759 for i, poem in ipairs(poems_data.poems) do
3760 if poem.poem_index then
3761 lookup[poem.poem_index] = poem
3762 end
3763 end
3764 return lookup
3765 end
3766 local poem_by_index = build_poem_by_index()
3767
3768 -- Issue 10-034: Convert similarity ranking (raw indices) to poem objects
3769 -- ranking_data is an array of poem indices received from orchestrator
3770 local function convert_similarity_ranking(ranking_data, source_poem_index)
3771 if not ranking_data then return {} end
3772 local result = {}
3773 for i, neighbor_index in ipairs(ranking_data) do
3774 local neighbor_poem = poem_by_index[neighbor_index]
3775 if neighbor_poem then
3776 table.insert(result, {
3777 poem = neighbor_poem,
3778 rank = i
3779 })
3780 end
3781 end
3782 return result
3783 end
3784
3785 -- Issue 10-034: Convert diversity sequence (raw indices) to poem objects
3786 -- sequence_data is an array of poem indices received from orchestrator
3787 -- Issue 10-025: Skip anchor poem (GPU cache stores source poem as first entry)
3788 local function convert_diversity_sequence(sequence_data, source_poem_index)
3789 if not sequence_data then return {} end
3790 local result = {}
3791 for step, neighbor_index in ipairs(sequence_data) do
3792 if neighbor_index ~= source_poem_index then
3793 local neighbor_poem = poem_by_index[neighbor_index]
3794 if neighbor_poem then
3795 table.insert(result, {
3796 id = neighbor_index,
3797 poem = neighbor_poem,
3798 step = step
3799 })
3800 end
3801 end
3802 end
3803 return result
3804 end
3805
3806 -- {{{ Issue 8-057: Boost formatting functions for worker thread
3807
3808 -- Worker: apply complete boost formatting. All geometry lives in
3809 -- the shared boost-bars module (top/inner/content/nav/bottom +
3810 -- the asymmetric fill-frontier right edge). The worker only splits
3811 -- the pre-wrapped content into lines; txt_fmt is unused now that
3812 -- the module owns visible-width padding.
3813 local function worker_apply_boost_formatting(content, progress_percent, similar_link, different_link, chronological_link, txt_fmt)
3814 local lines = {}
3815 for line in (content .. "\n"):gmatch("(.-)\n") do
3816 table.insert(lines, line)
3817 end
3818 local include_nav = (similar_link and different_link) and true or false
3819 return t_boost_bars.format_boost(
3820 lines, progress_percent, similar_link, different_link, chronological_link, include_nav)
3821 end
3822 -- }}} End Issue 8-057: Boost formatting functions
3823
3824 -- Local helper: Format single poem with full formatting (Issue 9-003 Fix D)
3825 -- Includes progress bars, navigation box, and chronological page links
3826 -- Issue 8-044: Added golden poem formatting support
3827 -- Issue 8-057: Added boost formatting support
3828 local function format_poem_entry(poem, poem_colors_tbl, clr_config, chrono_map, chrono_paged)
3829 -- Issue 9-013: a ranked IMAGE entry draws as an image box, not a poem.
3830 if poem.is_image then
3831 return t_image_render.format_image_entry(poem)
3832 end
3833 local poem_idx = poem.poem_index
3834 local poem_color_data = poem_colors_tbl[poem_idx]
3835 local semantic_color = poem_color_data and poem_color_data.color or "gray"
3836 local hex_color = clr_config[semantic_color] or clr_config["gray"]
3837 -- Hand the shared bar module this state's palette (idempotent).
3838 t_poem_bars.configure(clr_config)
3839
3840 -- Issue 8-044: Check if this is a golden poem
3841 local is_golden = is_golden_poem(poem)
3842
3843 -- Issue 8-057: Check if this is a boost and handle with special formatting
3844 local is_boost = is_boost_poem(poem)
3845
3846 -- Get chronological position from mapping
3847 local chrono_info = chrono_map[poem_idx] or {position = 1, page_number = 1, total_poems = 1, total_pages = 1, timeline_progress = 50}
3848 -- Issue 8-045: Use timeline_progress (time-based) instead of position-based
3849 -- Shows actual temporal position in the author's timeline, not just poem count
3850 local progress_pct = chrono_info.timeline_progress or ((chrono_info.position / chrono_info.total_poems) * 100)
3851
3852 -- Calculate progress bar chars
3853 -- Golden: 82 interior chars + 2 corners = 84 total
3854 -- Regular: 83 chars total (no corners on top bar)
3855 local total_bar_chars = is_golden and 82 or 83
3856 local progress_chars = math.floor((progress_pct / 100) * total_bar_chars)
3857 local remaining_chars = total_bar_chars - progress_chars
3858
3859 -- Top bar from the shared poem-bars module (canonical 83
3860 -- regular / 84 golden). progress_chars above is still used to
3861 -- progressively colour the regular nav corner boxes below.
3862 local colored_progress = t_poem_bars.progress_dashes(
3863 { percentage = progress_pct }, semantic_color, is_golden, "top", false).visual
3864
3865 -- Navigation links (absolute paths for local testing)
3866 -- Issue 9-003 Fix: Use absolute file:// paths - helper script converts to production URLs
3867 local base_path = ".."
3868 local similar_link = string.format("<a href='%s/similar/%04d-01.html'>similar</a>", base_path, poem_idx)
3869 local different_link = string.format("<a href='%s/different/%04d-01.html'>different</a>", base_path, poem_idx)
3870 -- Issue 16-006: Use poem_index for simpler, machine-readable anchor format
3871 -- Old format: "poem-fediverse-0042" (leaked category info)
3872 -- New format: "poem-4625" (just the unique poem_index)
3873 local anchor_id = string.format("poem-%d", poem.poem_index or 0)
3874
3875 -- Issue 8-039: Chronological link points to subdirectory
3876 local chrono_link
3877 if chrono_paged and chrono_info.total_pages > 1 then
3878 -- Paginated: chronological/01.html, chronological/02.html, etc.
3879 chrono_link = string.format("<a href='%s/chronological/%02d.html#%s'>chronological</a>",
3880 base_path, chrono_info.page_number, anchor_id)
3881 else
3882 -- Single page: chronological/index.html
3883 chrono_link = string.format("<a href='%s/chronological/index.html#%s'>chronological</a>", base_path, anchor_id)
3884 end
3885
3886 -- Issue 8-057: Handle boost poems with special nested frame formatting
3887 -- Boosts return early with their complete formatting (arrows, [BOOST] label, nested frames)
3888 if is_boost then
3889 local boost_content = poem.content or ""
3890 -- Escape HTML in content
3891 boost_content = boost_content:gsub("[%z\1-\8\11\12\14-\31]", ""):gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;")
3892
3893 -- Issue 10-037: Defensive fallback for blank boost content (worker thread)
3894 -- If content is empty, display the original URI or diagnostic message
3895 if boost_content == "" or boost_content:match("^%s*$") then
3896 local original_uri = poem.metadata and poem.metadata.original_uri
3897 if original_uri then
3898 -- Escape HTML in URI
3899 local safe_uri = original_uri:gsub("[%z\1-\8\11\12\14-\31]", ""):gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;")
3900 boost_content = "External post: " .. safe_uri
3901 else
3902 boost_content = "(Boost content unavailable)"
3903 end
3904 end
3905
3906 -- Issue 10-039: Make external boost URLs clickable (worker thread)
3907 -- Pattern: "External post: https://..." -> wrap URL in anchor tag
3908 local external_pattern = "^External post: (https?://[^%s]+)$"
3909 local external_url = boost_content:match(external_pattern)
3910 if external_url then
3911 -- Wrap the URL across box lines instead of overflowing.
3912 boost_content = t_text_formatter.wrap_external_url("External post: ", external_url, t_boost_bars.CONTENT_WIDTH)
3913 else
3914 -- Issue 10-041: Wrap long embedded content to fit the boost box.
3915 -- Only wrap non-external-post content (external posts keep URLs intact)
3916 local BOOST_CONTENT_WIDTH = t_boost_bars.CONTENT_WIDTH
3917 local wrapped_lines = {}
3918 for line in (boost_content .. "\n"):gmatch("(.-)\n") do
3919 local wrapped = t_text_formatter.wrap_preserving_indent(line, BOOST_CONTENT_WIDTH)
3920 for _, wrapped_line in ipairs(wrapped) do
3921 table.insert(wrapped_lines, wrapped_line)
3922 end
3923 end
3924 boost_content = table.concat(wrapped_lines, "\n")
3925 end
3926
3927 -- Calculate progress as decimal (0-1)
3928 local progress_decimal = progress_pct / 100
3929
3930 -- Apply boost formatting with all frame elements
3931 local boost_formatted = worker_apply_boost_formatting(
3932 boost_content, progress_decimal,
3933 similar_link, different_link, chrono_link,
3934 t_text_formatter
3935 )
3936
3937 -- Build output including any attached media
3938 local output = { boost_formatted }
3939
3940 -- Handle media attachments for boosts (same logic as regular poems)
3941 local media_base = ".."
3942 local has_media = false
3943 local media_atts = {}
3944 if poem.attachments and #poem.attachments > 0 then
3945 for _, att in ipairs(poem.attachments) do
3946 local mt = att.media_type or ""
3947 if mt:match("^image/") or mt:match("^audio/") or mt:match("^video/") then
3948 table.insert(media_atts, att)
3949 has_media = true
3950 end
3951 end
3952 end
3953
3954 if has_media then
3955 table.insert(output, "</pre>")
3956 for _, att in ipairs(media_atts) do
3957 local rpath = att.relative_path or ""
3958 -- media_href: namespace art by source+subdir,
3959 -- url-encode (this is the path that previously
3960 -- emitted the raw broken "...TROUBLE-U-?...png"
3961 -- link on the similarity pages); Mastodon stays flat.
3962 local media_src = "../media/" .. media_href(rpath)
3963 local media_type = att.media_type or "image/png"
3964 if media_type:match("^image/") then
3965 local alt = att.description and att.description ~= "" and att.description or "Image attachment"
3966 if att.width and att.height then
3967 table.insert(output, string.format(
3968 ' <img src="%s" alt="%s" loading="lazy" width="%d" height="%d" style="display:block; max-width:min(100%%,800px); height:auto">',
3969 media_src, alt, att.width, att.height
3970 ))
3971 else
3972 table.insert(output, string.format(
3973 ' <img src="%s" alt="%s" loading="lazy" style="display:block; max-width:min(100%%,800px); height:auto">',
3974 media_src, alt
3975 ))
3976 end
3977 elseif media_type:match("^audio/") then
3978 table.insert(output, string.format(
3979 ' <audio controls preload="metadata" style="display:block; max-width:100%%">\n' ..
3980 ' <source src="%s" type="%s">\n' ..
3981 ' Your browser does not support the audio element.\n' ..
3982 ' </audio>',
3983 media_src, media_type
3984 ))
3985 elseif media_type:match("^video/") then
3986 if att.width and att.height then
3987 table.insert(output, string.format(
3988 ' <video controls preload="metadata" width="%d" height="%d" style="display:block; max-width:min(100%%,800px); height:auto">\n' ..
3989 ' <source src="%s" type="%s">\n' ..
3990 ' Your browser does not support the video element.\n' ..
3991 ' </video>',
3992 att.width, att.height, media_src, media_type
3993 ))
3994 else
3995 table.insert(output, string.format(
3996 ' <video controls preload="metadata" style="display:block; max-width:min(100%%,800px); height:auto">\n' ..
3997 ' <source src="%s" type="%s">\n' ..
3998 ' Your browser does not support the video element.\n' ..
3999 ' </video>',
4000 media_src, media_type
4001 ))
4002 end
4003 end
4004 end
4005 table.insert(output, "<pre>")
4006 end
4007
4008 return table.concat(output, "\n")
4009 end
4010
4011 -- Standard formatting for golden and regular (non-boost) poems
4012 -- Wrap content to 80 chars while preserving paragraph breaks
4013 -- Also handle content warnings (CW: or content warning:)
4014 local content = poem.content or ""
4015
4016 -- Issue 8-041: Escape HTML special characters in poem content
4017 -- Prevents browser from interpreting poem content as HTML markup
4018 -- (e.g., a poem containing "</pre>" would otherwise close the preformatted block)
4019 -- Order: & first, then < and > (otherwise &lt; becomes &amp;lt;)
4020 content = content:gsub("[%z\1-\8\11\12\14-\31]", ""):gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;")
4021
4022 local wrapped_lines = {}
4023
4024 -- Issue 9-011: Display content warning from poem.content_warning field (Mastodon CW)
4025 -- This is separate from in-content CW: patterns - it comes from ActivityPub summary field
4026 if poem.content_warning and poem.content_warning ~= "" then
4027 -- Build box around ActivityPub content warning
4028 local cw_display = "CW: " .. poem.content_warning
4029 local box_width = math.min(math.max(#cw_display, 20), 76)
4030 local padded_cw = cw_display .. string.rep(" ", box_width - #cw_display)
4031 table.insert(wrapped_lines, " ┌" .. string.rep("─", box_width + 2) .. "┐")
4032 table.insert(wrapped_lines, " │ " .. padded_cw .. " │")
4033 table.insert(wrapped_lines, " └" .. string.rep("─", box_width + 2) .. "┘")
4034 table.insert(wrapped_lines, "") -- Empty line after CW
4035 table.insert(wrapped_lines, "") -- Second empty line for spacing
4036 end
4037
4038 -- Check for content warning at start
4039 local cw_text = nil
4040 local main_content = content
4041 local cw_match = content:match("^%s*[Cc][Ww]%s*:(.-)[\n\r]")
4042 if not cw_match then
4043 cw_match = content:match("^%s*[Cc]ontent [Ww]arning%s*:(.-)[\n\r]")
4044 end
4045 if cw_match then
4046 cw_text = cw_match:match("^%s*(.-)%s*$") -- trim whitespace
4047 -- Remove the CW line from main content
4048 main_content = content:gsub("^%s*[Cc][Ww]%s*:[^\n\r]*[\n\r]?", "")
4049 main_content = main_content:gsub("^%s*[Cc]ontent [Ww]arning%s*:[^\n\r]*[\n\r]?", "")
4050 end
4051
4052 -- If there's a content warning, format it in a box
4053 if cw_text and #cw_text > 0 then
4054 -- Build simple box around CW
4055 local cw_display = "CW: " .. cw_text
4056 local box_width = math.min(math.max(#cw_display, 20), 76)
4057 local padded_cw = cw_display .. string.rep(" ", box_width - #cw_display)
4058 table.insert(wrapped_lines, " ┌" .. string.rep("─", box_width + 2) .. "┐")
4059 table.insert(wrapped_lines, " │ " .. padded_cw .. " │")
4060 table.insert(wrapped_lines, " └" .. string.rep("─", box_width + 2) .. "┘")
4061 table.insert(wrapped_lines, "") -- Empty line after CW
4062 end
4063
4064 -- Issue 8-056: Preserve whitespace for ALL categories
4065 -- Poetry is artistic content - author's spacing must be respected
4066 -- Use shared text-formatter module for consistent behavior with main thread
4067 local content_lines = t_text_formatter.format_poem_content(main_content)
4068 for _, line in ipairs(content_lines) do
4069 table.insert(wrapped_lines, line)
4070 end
4071
4072 -- Issue 8-044: Apply golden side borders to content lines
4073 -- Golden poems get ║ (colored) on left and │ on right
4074 -- Total width: ║ (1) + space (1) + 80 chars content + space (1) + │ (1) = 84 total
4075 if is_golden then
4076 local golden_lines = {}
4077 local colored_wall = string.format('<font color="%s"><b>║</b></font>', hex_color)
4078 -- Issue 8-055: Use config layout values instead of hardcoded 80
4079 local CONTENT_WIDTH = config.layout and config.layout.text_content_width or 80
4080
4081 -- Helper to count UTF-8 characters (not bytes)
4082 -- Box-drawing chars are 3 bytes each, so #str gives wrong count
4083 local function utf8_char_count(str)
4084 -- Remove UTF-8 continuation bytes (0x80-0xBF), count what remains
4085 return #(str:gsub("[\128-\191]", ""))
4086 end
4087
4088 for _, line in ipairs(wrapped_lines) do
4089 -- Strip the leading space that word-wrap added (we'll add our own)
4090 local content = line:match("^%s*(.*)$") or line
4091
4092 -- Calculate visible length (excluding HTML tags, counting UTF-8 chars)
4093 -- Issue 8-055: Also decode HTML entities for accurate width counting
4094 -- e.g., &gt; is 4 bytes but displays as 1 character (>)
4095 local visible_length = t_text_formatter.calculate_visible_width(content)
4096
4097 -- Pad content to 80 chars
4098 local padded_content
4099 if visible_length >= CONTENT_WIDTH then
4100 padded_content = content
4101 else
4102 local padding_needed = CONTENT_WIDTH - visible_length
4103 padded_content = content .. string.rep(" ", padding_needed)
4104 end
4105
4106 -- Add side borders: ║ + space + 80 chars + space + │ = 84 total
4107 table.insert(golden_lines, colored_wall .. " " .. padded_content .. " │")
4108 end
4109 wrapped_lines = golden_lines
4110 end
4111
4112 -- Build navigation box matching reference implementation
4113 -- Regular poem structure: 83 chars total (positions 0-82)
4114 -- ┌─────────┐ (11 chars) + 59 spaces + ┌───────────┐ (13 chars) = 83 chars
4115
4116 -- Issue 8-035: Helper to colorize box characters based on progress
4117 local function color_char(char, pos)
4118 if progress_chars > pos then
4119 return string.format('<font color="%s"><b>%s</b></font>', hex_color, char)
4120 end
4121 return char
4122 end
4123
4124 -- Build nav_top and nav_mid
4125 -- Issue 8-044: Golden poems use different box characters
4126 -- Nav top + line come from the shared poem-bars module so this
4127 -- worker can't drift from the main thread (the whole point of
4128 -- the de-dup). Golden nav box dashes are not progress-tinted,
4129 -- which now matches the chronological (main-rendered) golden
4130 -- poems exactly.
4131 local nav_top, nav_mid
4132 if is_golden then
4133 nav_top = t_poem_bars.golden_corner_box_separator(hex_color, progress_chars)
4134 nav_mid = t_poem_bars.golden_corner_box_nav_line(similar_link, different_link, chrono_link, hex_color, progress_chars)
4135 else
4136 nav_top = t_poem_bars.corner_box_top(progress_chars, hex_color)
4137 nav_mid = t_poem_bars.corner_box_nav_line(similar_link, different_link, chrono_link, progress_chars, hex_color)
4138 end
4139
4140 -- Bottom line: delegate to the shared poem-bars module so this
4141 -- worker cannot drift from the main thread's canonical geometry.
4142 -- The old inline copy used the 82-char CONTENT width as the BAR
4143 -- width, so the bar ended one column short of the nav boxes (and
4144 -- an earlier version produced 88-char bars with doubled ╧╧).
4145 -- progress_dashes is correct for both regular (83) and golden
4146 -- (84) and seats the junctions under the corner-box walls.
4147 local bottom_line = t_poem_bars.progress_dashes(
4148 { percentage = progress_pct }, semantic_color, is_golden, "bottom", true).visual
4149
4150 -- Build formatted output
4151 local output = {}
4152 table.insert(output, colored_progress) -- Top progress bar (golden: 84 chars, regular: 83 chars)
4153 table.insert(output, table.concat(wrapped_lines, "\n")) -- Content with preserved newlines
4154
4155 -- Issue 8-040: Render attached images if present (from ActivityPub extraction)
4156 -- Images appear after poem content, before navigation links
4157 -- Must be inline since worker thread can't access main scope functions
4158 local base_path = ".."
4159
4160 -- Issue 8-049: Check if we have any media to render (images, audio, video)
4161 -- Issue 9-010: Media stays with their original post only (no associated_images rendering)
4162 local has_any_media = false
4163 local media_attachments = {}
4164 if poem.attachments and #poem.attachments > 0 then
4165 for _, att in ipairs(poem.attachments) do
4166 local mt = att.media_type or ""
4167 if mt:match("^image/") or mt:match("^audio/") or mt:match("^video/") then
4168 table.insert(media_attachments, att)
4169 has_any_media = true
4170 end
4171 end
4172 end
4173
4174 -- If there are media attachments, close </pre>, render them, reopen <pre>
4175 -- Issue 8-005 Fix: Media rendered outside <pre> for proper max-width behavior
4176 -- display:block prevents side-by-side, max-width:min(100%,800px) caps width
4177 if has_any_media then
4178 table.insert(output, "</pre>")
4179 for _, attachment in ipairs(media_attachments) do
4180 -- Issue 8-048: Use flat output/media/ path structure
4181 local relative_path = attachment.relative_path or ""
4182 -- media_href: namespace art by source+subdir (collision-
4183 -- safe) + url-encode; Mastodon hashes stay flat.
4184 local media_src = base_path .. "/media/" .. media_href(relative_path)
4185 local media_type = attachment.media_type or ""
4186
4187 if media_type:match("^image/") then
4188 local alt_text = attachment.description or attachment.alt_text or "Image attachment"
4189 -- Issue 8-053: Normalize newlines to spaces for clean HTML attributes
4190 alt_text = alt_text:gsub("\n", " "):gsub("\r", "")
4191 alt_text = alt_text:gsub('"', '&quot;')
4192 -- Issue 8-053: title attribute provides mouse-over tooltip
4193 local img_tag = string.format(
4194 ' <img src="%s" alt="%s" title="%s" loading="lazy" style="display:block; max-width:min(100%%,800px); height:auto"',
4195 media_src, alt_text, alt_text
4196 )
4197 if attachment.width and attachment.height then
4198 img_tag = img_tag .. string.format(' width="%d" height="%d"', attachment.width, attachment.height)
4199 end
4200 img_tag = img_tag .. '>'
4201 table.insert(output, img_tag)
4202
4203 elseif media_type:match("^audio/") then
4204 -- Issue 8-049: Audio playback support
4205 local audio_tag = string.format(
4206 ' <audio controls preload="metadata" style="display:block; max-width:100%%">\n' ..
4207 ' <source src="%s" type="%s">\n' ..
4208 ' Your browser does not support the audio element.\n' ..
4209 ' </audio>',
4210 media_src, media_type
4211 )
4212 table.insert(output, audio_tag)
4213
4214 elseif media_type:match("^video/") then
4215 -- Issue 8-049: Video playback support
4216 local video_tag
4217 if attachment.width and attachment.height then
4218 video_tag = string.format(
4219 ' <video controls preload="metadata" width="%d" height="%d" style="display:block; max-width:min(100%%,800px); height:auto">\n' ..
4220 ' <source src="%s" type="%s">\n' ..
4221 ' Your browser does not support the video element.\n' ..
4222 ' </video>',
4223 attachment.width, attachment.height, media_src, media_type
4224 )
4225 else
4226 video_tag = string.format(
4227 ' <video controls preload="metadata" style="display:block; max-width:min(100%%,800px); height:auto">\n' ..
4228 ' <source src="%s" type="%s">\n' ..
4229 ' Your browser does not support the video element.\n' ..
4230 ' </video>',
4231 media_src, media_type
4232 )
4233 end
4234 table.insert(output, video_tag)
4235 end
4236 end
4237 table.insert(output, "<pre>")
4238 end
4239
4240 table.insert(output, nav_top) -- Nav box top (golden: 84, regular: 83 chars)
4241 table.insert(output, nav_mid) -- Nav box middle
4242 table.insert(output, bottom_line) -- Bottom with junctions
4243
4244 return table.concat(output, "\n")
4245 end
4246
4247 -- Local helper: Generate paginated HTML page
4248 -- Issue 9-003 Fix D: Added chrono_map and chrono_paged for full formatting
4249 local function generate_page(poem, sorted_list, page_type, page_num, poems_per_pg, out_dir, chrono_map, chrono_paged)
4250 local start_idx = (page_num - 1) * poems_per_pg + 1
4251 local end_idx = math.min(start_idx + poems_per_pg - 1, #sorted_list)
4252 if start_idx > #sorted_list then return nil end
4253
4254 local type_label = page_type == "similar" and "similarity" or "diversity"
4255 local poem_idx_str = string.format("%04d", poem.poem_index or 0)
4256 local filename = string.format("%s/%s/%s-%02d.html", out_dir, page_type, poem_idx_str, page_num)
4257
4258 -- Build HTML content with full formatting
4259 -- Issue 9-003 Fix: Use centered table for block centering with left-aligned text inside
4260 -- Issue 16-010: Added inline font style for Hack Nerd Font font-stack
4261 local font_style = [[<style>body, pre { font-family: 'Hack Nerd Font', 'Hack', 'Fira Code', 'JetBrains Mono', 'Cascadia Code', 'Consolas', 'Monaco', 'Liberation Mono', 'Courier New', monospace; }</style>]]
4262 local html_parts = {
4263 '<!DOCTYPE html><html><head><meta charset="UTF-8">',
4264 '<title>Poems by ' .. type_label .. ' to poem ' .. poem_idx_str .. ' (page ' .. page_num .. ')</title>',
4265 font_style,
4266 '</head><body bgcolor="#000000" text="#FFFFFF" link="#6699FF" vlink="#9966FF"><table align="center"><tr><td><pre>'
4267 }
4268
4269 -- Add anchor poem with full formatting
4270 table.insert(html_parts, "=== ANCHOR POEM ===\n")
4271 table.insert(html_parts, format_poem_entry(poem, poem_colors, color_config, chrono_map, chrono_paged))
4272 table.insert(html_parts, "\n\n=== " .. type_label:upper() .. " RANKED ===\n\n")
4273
4274 -- Add poems for this page with full formatting
4275 for i = start_idx, end_idx do
4276 local entry = sorted_list[i]
4277 local entry_poem = entry.poem
4278 if entry_poem then
4279 -- Issue 8-036: Add poem source path to ranking header
4280 local source_path = get_source_path(entry_poem)
4281 table.insert(html_parts, string.format("--- #%d %s ---\n", i, source_path))
4282 -- Issue 9-013: text+image posts get a direct "image.png"
4283 -- link below their header (image entries are skipped --
4284 -- their title already deep-links into the gallery).
4285 if not entry_poem.is_image then
4286 local img_link = t_image_render.text_image_link(entry_poem)
4287 if img_link ~= "" then
4288 table.insert(html_parts, " " .. img_link .. "\n")
4289 end
4290 end
4291 table.insert(html_parts, format_poem_entry(entry_poem, poem_colors, color_config, chrono_map, chrono_paged))
4292 table.insert(html_parts, "\n\n")
4293 end
4294 end
4295
4296 table.insert(html_parts, '</pre></td></tr></table></body></html>')
4297
4298 -- Write file
4299 local dir_path = filename:match("(.*/)")
4300 os.execute('mkdir -p "' .. dir_path .. '"')
4301 local f = io.open(filename, "w")
4302 if f then
4303 f:write(table.concat(html_parts))
4304 f:close()
4305 return filename
4306 end
4307 return nil
4308 end
4309
4310 -- Issue 10-034: Orchestrator request/response loop
4311 -- Workers request work, receive slices, generate pages, report completion
4312 local similarity_count = 0
4313 local diversity_count = 0
4314 local processed = 0
4315
4316 while true do
4317 -- Request work from orchestrator
4318 request_channel:push({
4319 type = "get_work",
4320 worker_id = tid
4321 })
4322
4323 -- Wait for response (blocks until data available)
4324 local work = response_channel:pop()
4325
4326 if not work then
4327 -- Channel closed or error
4328 break
4329 end
4330
4331 if work.type == "shutdown" then
4332 -- No more work, exit loop
4333 break
4334 end
4335
4336 if work.type == "work" then
4337 local poem_index = work.poem_index
4338 local poem = poem_lookup[poem_index]
4339
4340 if poem then
4341 -- Convert raw index arrays to poem objects using data from orchestrator
4342 local similar_ranking = convert_similarity_ranking(work.similarity_ranking, poem_index)
4343 local diverse_sequence = convert_diversity_sequence(work.diversity_sequence, poem_index)
4344
4345 -- Generate similarity pages (page 1 only, respecting config)
4346 -- Issue 9-003 Fix D: Pass chrono_mapping and chrono_paginated for full formatting
4347 local max_pages = config.pages_is_all and 1 or (config.pages_list and #config.pages_list or 1)
4348 for page_num = 1, max_pages do
4349 local page_file = generate_page(poem, similar_ranking, "similar", page_num, config.poems_per_page, config.output_dir, config.chrono_mapping, config.chrono_paginated)
4350 if page_file then similarity_count = similarity_count + 1 end
4351 end
4352
4353 -- Generate diversity pages
4354 for page_num = 1, max_pages do
4355 local page_file = generate_page(poem, diverse_sequence, "different", page_num, config.poems_per_page, config.output_dir, config.chrono_mapping, config.chrono_paginated)
4356 if page_file then diversity_count = diversity_count + 1 end
4357 end
4358
4359 processed = processed + 1
4360
4361 -- Report completion to orchestrator
4362 request_channel:push({
4363 type = "done",
4364 worker_id = tid,
4365 poem_index = poem_index
4366 })
4367 end
4368 end
4369 end
4370
4371 return similarity_count, diversity_count, processed
4372 end)
4373
4374 -- Launch thread with channels for orchestrator communication
4375 threads[thread_id] = thread_func(thread_config, thread_id, work_request_channel, work_response_channels[thread_id])
4376 end
4377
4378 -- Issue 10-034: Orchestrator loop - serves work slices to workers
4379 -- Main thread holds caches, sends ~80KB slices instead of workers loading 700MB
4380
4381 -- Track work state
4382 local work_queue_idx = 1 -- Next poem index to assign
4383 local completed_count = 0 -- Number of poems completed
4384 local workers_active = num_threads -- Number of workers still running
4385 local workers_shutdown = {} -- Track which workers have been told to shut down
4386 for t = 1, num_threads do
4387 workers_shutdown[t] = false
4388 end
4389
4390 -- Get references to caches loaded in main thread (lines 3092-3096)
4391 -- DIVERSITY_CACHE and SIMILARITY_RANKINGS_CACHE are module-level variables
4392 local similarity_cache = SIMILARITY_RANKINGS_CACHE
4393 local diversity_cache = DIVERSITY_CACHE
4394
4395 -- Progress tracking
4396 local last_progress_time = os.time()
4397 local progress_interval = 1 -- Update progress every 1 second
4398
4399 -- Orchestrator main loop: process requests until all work done and all workers shut down
4400 while workers_active > 0 do
4401 -- Non-blocking receive with short timeout (100ms)
4402 local msg = work_request_channel:pop(100)
4403
4404 if msg then
4405 if msg.type == "get_work" then
4406 local worker_id = msg.worker_id
4407
4408 if work_queue_idx <= total_work then
4409 -- Get next poem index from queue
4410 local poem_index = work_queue[work_queue_idx]
4411 work_queue_idx = work_queue_idx + 1
4412
4413 -- Extract work slice from caches (~80KB: similarity ranking + diversity sequence)
4414 local similarity_ranking = similarity_cache.rankings[tostring(poem_index)]
4415 local diversity_sequence = diversity_cache.sequences[tostring(poem_index)]
4416
4417 -- Send work slice to worker
4418 work_response_channels[worker_id]:push({
4419 type = "work",
4420 poem_index = poem_index,
4421 similarity_ranking = similarity_ranking,
4422 diversity_sequence = diversity_sequence
4423 })
4424 else
4425 -- No more work - tell worker to shut down
4426 if not workers_shutdown[worker_id] then
4427 work_response_channels[worker_id]:push({
4428 type = "shutdown"
4429 })
4430 workers_shutdown[worker_id] = true
4431 workers_active = workers_active - 1
4432 end
4433 end
4434
4435 elseif msg.type == "done" then
4436 -- Worker completed a poem
4437 completed_count = completed_count + 1
4438 end
4439 end
4440
4441 -- Update progress display periodically
4442 local now = os.time()
4443 if now - last_progress_time >= progress_interval then
4444 last_progress_time = now
4445
4446 local elapsed = now - start_time
4447 local rate = elapsed > 0 and (completed_count / elapsed) or 0
4448 local remaining = total_work - completed_count
4449 local eta = rate > 0 and math.floor(remaining / rate) or 0
4450 local pct = (completed_count / total_work) * 100
4451
4452 -- Show orchestrator progress as the shared bar. The rate, ETA,
4453 -- and remaining queue depth ride along as the suffix.
4454 local label = string.format(" [%d threads]", num_threads)
4455 local suffix = string.format("%.1f poems/sec | ETA: %ds | Queue: %d",
4456 rate, eta, total_work - work_queue_idx + 1)
4457 progress.update(label, completed_count, total_work, suffix)
4458 end
4459 end
4460
4461 -- Close the animated bar, then print a plain completion summary so it
4462 -- survives in logs (the bar itself is suppressed when piped/quiet).
4463 progress.finish()
4464 local elapsed = os.time() - start_time
4465 print(string.format(" [%d threads] Complete: %d poems in %ds (%.1f poems/sec)",
4466 num_threads, completed_count, elapsed, completed_count / math.max(elapsed, 1)))
4467
4468 -- Wait for all threads to fully complete and collect results
4469 local total_similarity = 0
4470 local total_diversity = 0
4471 local total_processed = 0
4472
4473 for tid, thread in pairs(threads) do
4474 -- Wait for thread completion (may already be done)
4475 local status = thread:wait()
4476 if status == "completed" then
4477 local sim_count, div_count, proc_count = thread:get()
4478 total_similarity = total_similarity + (sim_count or 0)
4479 total_diversity = total_diversity + (div_count or 0)
4480 total_processed = total_processed + (proc_count or 0)
4481 elseif status == "failed" then
4482 local err = thread:get()
4483 utils.log_error(string.format("Thread %d failed: %s", tid, tostring(err)))
4484 else
4485 utils.log_warn(string.format("Thread %d in unexpected state: %s", tid, status))
4486 end
4487 end
4488
4489 -- Update results counts (we don't have individual filenames in parallel mode)
4490 for i = 1, total_similarity do table.insert(results.similarity_pages, "parallel") end
4491 for i = 1, total_diversity do table.insert(results.diversity_pages, "parallel") end
4492 -- }}} End parallel processing
4493
4494 else
4495 -- {{{ Sequential processing (original code path)
4496 if num_threads > 1 and not has_threading then
4497 utils.log_warn("Parallel processing requested but effil not available, using single thread")
4498 end
4499
4500 -- Generate similarity and diversity pages for each poem
4501 -- Note: Loop variable is poem_index (globally unique) not poem.id (per-category)
4502 local progress_count = 0
4503 for poem_index, poem_data in pairs(valid_poems) do
4504 progress_count = progress_count + 1
4505
4506 -- Animate one progress line; throttle sparser under --debug (verbose).
4507 local step = (progress.mode() == 2) and 100 or 25
4508 if progress_count % step == 0 then
4509 progress.update(" 📄 HTML pages", progress_count, total_poems)
4510 end
4511
4512 -- Generate unique filename identifier (category prefix for cross-category uniqueness)
4513 local unique_id = get_unique_poem_filename_id(poem_data)
4514
4515 -- Generate similarity ranking (cache is keyed by poem_index)
4516 local similar_ranking = M.generate_similarity_ranked_list(poem_index, poems_data, similarity_data)
4517
4518 -- Phase D (Issue 8-012): Use paginated generation
4519 -- Note: Pagination uses poem_index (numeric) for file naming (similar/0001-01.html)
4520 local pagination_result = M.generate_all_paginated_pages_for_poem(
4521 poem_data,
4522 similar_ranking,
4523 "similar",
4524 poem_data.poem_index, -- Use numeric poem_index for pagination filenames
4525 output_dir,
4526 pages_config.is_all and nil or pages_config.pages, -- nil means "all pages"
4527 -- Issue 10-036: the mapping computed above must travel with the
4528 -- work, or the formatter guesses chronological page 01 for every
4529 -- poem. It was in scope here all along and simply not handed over.
4530 chrono_mapping,
4531 chronological_paginated
4532 )
4533
4534 if pagination_result and pagination_result.files_generated then
4535 for _, file in ipairs(pagination_result.files_generated) do
4536 table.insert(results.similarity_pages, file)
4537 end
4538 end
4539
4540 -- Generate TXT version. NOT a full-corpus export, despite what this comment
4541 -- used to claim: it receives the SAME ranked list the pages are built
4542 -- from, already trimmed, so it holds what the reader is looking at
4543 -- (~90 entries), unsplit by page. The old wording sent a reader
4544 -- looking for a whole-collection download that is not produced here.
4545 local similar_txt = generate_similarity_txt_file(poem_data, similar_ranking,
4546 string.format("%s/similar/%s.txt", output_dir, unique_id))
4547 if similar_txt then
4548 table.insert(results.txt_files, similar_txt)
4549 end
4550
4551 -- Generate HTML archive version: the same ranked list as the pages, with
4552 -- images, in one unsplit file. Off by default, which is why the page
4553 -- only advertises it when generate_html_archives is on.
4554 -- Issue 10-036: Pass chrono_mapping for correct paginated chronological links
4555 if PAGINATION_CONFIG.generate_html_archives then
4556 local similar_archive = generate_similarity_html_archive(poem_data, similar_ranking,
4557 string.format("%s/similar/%s-archive.html", output_dir, unique_id), chrono_mapping, chronological_paginated)
4558 if similar_archive then
4559 table.insert(results.html_archives, similar_archive)
4560 end
4561 end
4562
4563 -- Generate diversity pages (cache is keyed by poem_index)
4564 local diverse_sequence = M.generate_maximum_diversity_sequence(poem_index, poems_data, embeddings_data)
4565
4566 -- Phase D (Issue 8-012): Use paginated generation for diversity pages too
4567 -- Note: Pagination uses poem_index (numeric) for file naming (different/0001-01.html)
4568 local diversity_pagination_result = M.generate_all_paginated_pages_for_poem(
4569 poem_data,
4570 diverse_sequence,
4571 "different",
4572 poem_data.poem_index, -- Use numeric poem_index for pagination filenames
4573 output_dir,
4574 pages_config.is_all and nil or pages_config.pages, -- nil means "all pages"
4575 -- Issue 10-036: same mapping, same reason as the "similar" call above.
4576 chrono_mapping,
4577 chronological_paginated
4578 )
4579
4580 if diversity_pagination_result and diversity_pagination_result.files_generated then
4581 for _, file in ipairs(diversity_pagination_result.files_generated) do
4582 table.insert(results.diversity_pages, file)
4583 end
4584 end
4585
4586 -- Generate TXT version. NOT a full-corpus export, despite what this comment
4587 -- used to claim: it receives the SAME ranked list the pages are built
4588 -- from, already trimmed, so it holds what the reader is looking at
4589 -- (~90 entries), unsplit by page. The old wording sent a reader
4590 -- looking for a whole-collection download that is not produced here.
4591 local diverse_txt = generate_diversity_txt_file(poem_data, diverse_sequence,
4592 string.format("%s/different/%s.txt", output_dir, unique_id))
4593 if diverse_txt then
4594 table.insert(results.txt_files, diverse_txt)
4595 end
4596
4597 -- Generate HTML archive version: the same ranked list as the pages, with
4598 -- images, in one unsplit file. Off by default, which is why the page
4599 -- only advertises it when generate_html_archives is on.
4600 -- Issue 10-036: Pass chrono_mapping for correct paginated chronological links
4601 if PAGINATION_CONFIG.generate_html_archives then
4602 local diverse_archive = generate_diversity_html_archive(poem_data, diverse_sequence,
4603 string.format("%s/different/%s-archive.html", output_dir, unique_id), chrono_mapping, chronological_paginated)
4604 if diverse_archive then
4605 table.insert(results.html_archives, diverse_archive)
4606 end
4607 end
4608 end
4609 -- }}} End sequential processing
4610 progress.finish()
4611 end
4612
4613 -- Note: Chronological index and explore.html are generated by main.lua before this function
4614 -- to avoid duplicate work. We only generate the TXT export here.
4615
4616 -- Generate chronological TXT export (not generated elsewhere)
4617 local chrono_txt_file = output_dir .. "/chronological.txt"
4618 local chrono_txt = M.generate_chronological_txt_file(poems_data, chrono_txt_file)
4619 if chrono_txt then
4620 table.insert(results.txt_files, chrono_txt)
4621 results.chronological_txt = chrono_txt
4622 end
4623
4624 return results
4625end
4626-- }}}
4627
4628-- {{{ function M.main
4629function M.main(interactive_mode)
4630 if interactive_mode then
4631 print("Flat HTML Generator - Interactive Mode")
4632 print("1. Generate complete flat HTML collection")
4633 print("2. Generate chronological index only")
4634 print("3. Generate instructions page only")
4635 print("4. Test single similarity page")
4636 print("5. Test single difference page")
4637 io.write("Select option (1-5): ")
4638 local choice = io.read()
4639
4640 local poems_file = utils.asset_path("poems.json")
4641 local similarity_file = utils.embeddings_dir() .. "/similarity_matrix.json"
4642 local embeddings_file = utils.embeddings_dir() .. "/embeddings.json"
4643 local output_dir = DIR .. "/output"
4644
4645 if choice == "1" then
4646 utils.log_info("Loading data files...")
4647 local poems_data = utils.read_json_file(poems_file)
4648 local similarity_data = utils.read_json_file(similarity_file)
4649 local embeddings_data = utils.read_json_file(embeddings_file)
4650
4651 if poems_data and similarity_data and embeddings_data then
4652 M.generate_complete_flat_html_collection(poems_data, similarity_data.similarities, embeddings_data, output_dir)
4653 else
4654 utils.log_error("Failed to load required data files")
4655 end
4656 elseif choice == "2" then
4657 local poems_data = utils.read_json_file(poems_file)
4658 if poems_data then
4659 M.generate_chronological_index_with_navigation(poems_data, output_dir)
4660 M.generate_chronological_txt_file(poems_data, output_dir .. "/chronological.txt")
4661 utils.log_info("Generated chronological/index.html and chronological.txt")
4662 end
4663 elseif choice == "3" then
4664 M.generate_simple_discovery_instructions(output_dir)
4665 elseif choice == "4" then
4666 io.write("Enter poem ID for similarity test: ")
4667 local poem_id = tonumber(io.read())
4668 if poem_id then
4669 local poems_data = utils.read_json_file(poems_file)
4670 local similarity_data = utils.read_json_file(similarity_file)
4671
4672 if poems_data and similarity_data then
4673 local poem_data = nil
4674 for _, poem in ipairs(poems_data.poems) do
4675 if poem.id == poem_id then
4676 poem_data = poem
4677 break
4678 end
4679 end
4680
4681 if poem_data then
4682 local ranking = M.generate_similarity_ranked_list(poem_id, poems_data, similarity_data.similarities)
4683 -- Issue 10-036: nil chrono_mapping here on purpose -- interactive test, not the
4684 -- site build. The formatter warns once and falls back to
4685 -- chronological/index.html, which exists in both modes.
4686 local html = M.generate_flat_poem_list_html(poem_data, ranking, "similar", poem_id, nil)
4687 local test_file = string.format("%s/test_similar_%03d.html", output_dir, poem_id)
4688 os.execute("mkdir -p " .. output_dir)
4689 utils.write_file(test_file, html)
4690 utils.log_info("Test file written: " .. test_file)
4691 end
4692 end
4693 end
4694 elseif choice == "5" then
4695 io.write("Enter poem ID for difference test: ")
4696 local poem_id = tonumber(io.read())
4697 if poem_id then
4698 local poems_data = utils.read_json_file(poems_file)
4699 local embeddings_data = utils.read_json_file(embeddings_file)
4700
4701 if poems_data and embeddings_data then
4702 local poem_data = nil
4703 for _, poem in ipairs(poems_data.poems) do
4704 if poem.id == poem_id then
4705 poem_data = poem
4706 break
4707 end
4708 end
4709
4710 if poem_data then
4711 local sequence = M.generate_maximum_diversity_sequence(poem_id, poems_data, embeddings_data)
4712 -- Issue 10-036: nil chrono_mapping here on purpose -- interactive test, not the
4713 -- site build. The formatter warns once and falls back to
4714 -- chronological/index.html, which exists in both modes.
4715 local html = M.generate_flat_poem_list_html(poem_data, sequence, "different", poem_id, nil)
4716 local test_file = string.format("%s/test_different_%03d.html", output_dir, poem_id)
4717 os.execute("mkdir -p " .. output_dir)
4718 utils.write_file(test_file, html)
4719 utils.log_info("Test file written: " .. test_file)
4720 end
4721 end
4722 end
4723 end
4724 else
4725 utils.log_info("Use -I flag for interactive mode")
4726 end
4727end
4728-- }}}
4729
4730-- Command line execution (only when run directly, not when require()'d)
4731-- arg[0] contains the script name - check if it matches this file
4732if arg and arg[0] and arg[0]:match("flat%-html%-generator%.lua$") then
4733 -- Check for interactive flag
4734 local interactive = false
4735 for _, arg_val in ipairs(arg) do
4736 if arg_val == "-I" then
4737 interactive = true
4738 break
4739 end
4740 end
4741
4742 M.main(interactive)
4743end
4744
4745return M
4746