From 3423a10d4314e37872aa7a79e322fa84dbc95c7f Mon Sep 17 00:00:00 2001 From: Peter Sefton Date: Thu, 3 Sep 2026 07:27:05 +1000 Subject: [PATCH 1/3] crate2tables: in-app config tree editor, _config/_outputs paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces "hand-edit crate2tables-config.json in a text editor" (Phase 1) with an actual UI, per the standing request to build the config picker described in the plugin spec's Phase 2: - Config moves to _config/roctable/config.json, output to _outputs/roctable/ — chaos2crate issue #81's proposed per-plugin directory convention, adopted here ahead of it becoming repo-wide. - First build against a folder with no config blocks on a checkbox tree editor (config-tree-ui.js): one heading per discovered @type, tick to select it as a table, unroll to choose include/expand/ load_text/join per property, one level deeper for an expanded property's own sub-properties. Confirming writes the config and that same build continues into extraction — not a seed-then-rebuild two-step. Dismissing without confirming cancels the build. - Every later build uses the existing config directly, no prompt. "Configure tables…" (a new action child) reopens the same editor on demand, reading the folder's crate directly via loadCrateFromJson — no build has to run first, other than the one that created the crate. - discover.js factors out the one discovery+merge path both the build-time hook and the standalone action call, adding one default: ldac:mainText (falling back to indexableText when a type has none) seeds include+load_text the first time that property is seen on a type, and is never touched again once it's been through one config round-trip — checked by presence in the config that existed before the call, not by shape, since a person deliberately declining it looks identical to a never-seen property otherwise. Depends on this repo's chaos2crate companion PR for two core additions "Configure tables…" needs: a nested type:"action" child (the same idea as the existing top-level action tile, just usable inside a group) and loadCrateFromJson added to buildDeps(). Verified: discoverConfig's default-seeding against all four cases (present fresh, absent-with-fallback, already-declined, already- customised); the tree UI's full interaction surface against a minimal fake DOM (type selection, include/expand/load_text/join, sub-property rendering, Cancel/dismiss/Save); the whole plugin end-to-end — cancellation, confirmed selection through to written CSV, a second build skipping the modal, and the standalone action with and without an existing crate — against a real 764-entity F2F collection, including confirming ldac:mainText seeds correctly on real RepositoryObject data and the resulting CSV contains genuinely loaded transcript text. --- README.md | 21 ++- src/crate2tables/config-tree-ui.js | 249 +++++++++++++++++++++++++++++ src/crate2tables/discover.js | 52 ++++++ src/crate2tables/index.js | 105 ++++++++---- 4 files changed, 395 insertions(+), 32 deletions(-) create mode 100644 src/crate2tables/config-tree-ui.js create mode 100644 src/crate2tables/discover.js diff --git a/README.md b/README.md index fa70769..cc8720b 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ handlers close over. Call it once, before the plugin's hooks can fire. | `file-format-identify` | `graphEntityById` (handed to `matcher.js`'s own `configure(deps)` on each dynamic import, for `getFileHandleAtPath`) | | `ca-data-prep` | `writeFileAtPath` | | `merge` | `readJsonFromFolder`, `graphEntityById` | -| `crate2tables` | `readJsonFromFolder`, `writeFileAtPath`, `getFileHandleAtPath`, `readFileTextFromDirectory` | +| `crate2tables` | `readJsonFromFolder`, `writeFileAtPath`, `getFileHandleAtPath`, `readFileTextFromDirectory`, `loadCrateFromJson` (lets "Configure tables…" inspect the folder's crate without a build running), `openModal` (the table-selection tree, `config-tree-ui.js`) | | `validate-crate` | `loadMasp` | | `ro-crate-json-output` | `crateToJsonString`, `writeFile`, `fileExists` | | `ro-crate-xlsx-output` | `crateToXlsxBytes`, `writeFile`, `fileExists` | @@ -81,6 +81,17 @@ load/save and CSV file writing stay this plugin's own job either way — roctable's `lib/config.js`/`lib/csv.js` file I/O is Node-`fs`-only and simply isn't called from here; see `chaos2crate/docs/crate2tables-spec.md`. +`crate2tables` is split across three files: `index.js` (the plugin itself — +hooks, the `optionSchema`, the `crate2tablesConfigure` action), `discover.js` +(inspect the crate and merge onto whatever config already exists — the one +code path both the build-time hook and the standalone action call, plus the +`ldac:mainText`/`indexableText` default-seeding rule), and `config-tree-ui.js` +(the checkbox-tree editor `openModal` renders — a table heading per `@type`, +unrolling to its properties' include/expand/load_text/join). Config lives at +`_config/roctable/config.json`, output at `_outputs/roctable/` — chaos2crate +issue #81's proposed per-plugin directory convention, adopted here ahead of +it becoming repo-wide. + ## Writing a new plugin here ```js @@ -139,6 +150,14 @@ Rules of thumb: - **No `outputPaths` at all is correct for a plugin that never writes to the folder** — `merge`, `austlang`, `validate-crate`, and the input-analysis half of every plugin all fall here; only the writing side declares. +- **A path under `_config//` or `_backup//` (chaos2crate issue + #81's proposed per-plugin directories) still gets scan-excluded, but + chaos2crate's "Delete plugin output before rebuilding" skips deleting it** + — those two are meant to persist across builds (standing configuration, + changed-file backups), unlike `_outputs//`, which is exactly the + disposable generated content that setting exists to clear. `crate2tables` + is the first plugin here to use this: config at `_config/roctable/`, + CSVs at `_outputs/roctable/`. Then register it in this repo's `index.js` (`REGISTRY` for an additive plugin, `INPUT_REGISTRY` for a mutually-exclusive input mode), and in diff --git a/src/crate2tables/config-tree-ui.js b/src/crate2tables/config-tree-ui.js new file mode 100644 index 0000000..a43dce6 --- /dev/null +++ b/src/crate2tables/config-tree-ui.js @@ -0,0 +1,249 @@ +// The in-app editor for a roctable config: one collapsible heading per +// discovered @type (tick to select it as a table), each unrolling to a list +// of its properties (include/expand/load_text/join), an expanded property +// further unrolling to its own one-hop sub-properties. Built the same +// "no host markup, no HTML string" way as new-files-confirm.js, using the +// host's own `.checkbox`/`.modal .actions`/`.secondary`/`.hint` CSS. +// +// The working copy is mutated directly by each control's own change handler +// rather than re-derived from DOM state at the end — Save just resolves +// with whatever the working copy currently is. + +function cloneConfig(config) { + return JSON.parse(JSON.stringify(config)); +} + +function allTypeNames(config) { + return [...new Set([...Object.keys(config.tables || {}), ...Object.keys(config.potential_tables || {})])].sort(); +} + +function tableEntry(config, type) { + return config.tables[type] || config.potential_tables[type]; +} + +function isSelected(config, type) { + return Object.prototype.hasOwnProperty.call(config.tables, type); +} + +function setTableSelected(config, type, selected) { + const entry = tableEntry(config, type); + if (selected) { + delete config.potential_tables[type]; + config.tables[type] = entry; + } else { + delete config.tables[type]; + config.potential_tables[type] = entry; + } +} + +function checkboxWithLabel(text, checked, onChange) { + const label = document.createElement("label"); + label.className = "checkbox"; + label.style.cssText = "gap:4px; font-size:13px; margin-right:14px;"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = checked; + input.addEventListener("change", () => onChange(input.checked)); + const span = document.createElement("span"); + span.textContent = text; + label.append(input, span); + return label; +} + +function includedCount(properties) { + const values = Object.values(properties || {}); + return { included: values.filter((p) => p.include).length, total: values.length }; +} + +// A one-hop expanded sub-property row: include only (rename exists in the +// config format but has no UI control yet — see docs/crate2tables-spec.md). +function renderSubPropertyRow(properties, name) { + const propConfig = properties[name]; + const wrap = document.createElement("div"); + wrap.style.padding = "2px 0"; + wrap.appendChild(checkboxWithLabel(name, propConfig.include !== false, (checked) => { propConfig.include = checked; })); + return wrap; +} + +// One property of a selected/potential table: include/expand/load_text/join, +// unrolling to its own sub-properties (discoverExpandedProperties' nested +// `properties` map) when expand is checked and that map already exists — +// it only appears after the type has been through a build with expand:true +// set, so a freshly-checked expand shows a hint instead until then. +function renderPropertyRow(properties, name, onIncludeChange) { + const propConfig = properties[name]; + const row = document.createElement("div"); + row.style.cssText = "display:flex; align-items:center; flex-wrap:wrap; padding:3px 0;"; + + const subPanel = document.createElement("div"); + subPanel.style.cssText = `margin:2px 0 6px 26px; ${propConfig.expand ? "" : "display:none;"}`; + + function renderSubPanel() { + subPanel.replaceChildren(); + if (propConfig.properties && Object.keys(propConfig.properties).length) { + for (const subName of Object.keys(propConfig.properties).sort()) { + subPanel.appendChild(renderSubPropertyRow(propConfig.properties, subName)); + } + } else { + const hint = document.createElement("div"); + hint.className = "hint"; + hint.textContent = "Sub-properties appear here after the next build re-discovers this expansion."; + subPanel.appendChild(hint); + } + } + renderSubPanel(); + + const includeCb = checkboxWithLabel(name, !!propConfig.include, (checked) => { + propConfig.include = checked; + onIncludeChange(); + }); + + const expandCb = checkboxWithLabel("expand", !!propConfig.expand, (checked) => { + propConfig.expand = checked; + subPanel.style.display = checked ? "block" : "none"; + if (checked) renderSubPanel(); + }); + + const joinSelect = document.createElement("select"); + joinSelect.disabled = !propConfig.load_text; + const opts = [["", "(plain text)"], ["csv", "join as CSV rows"]]; + for (const [value, text] of opts) { + const opt = document.createElement("option"); + opt.value = value; opt.textContent = text; + joinSelect.appendChild(opt); + } + joinSelect.value = propConfig.join || ""; + joinSelect.addEventListener("change", () => { + if (joinSelect.value) propConfig.join = joinSelect.value; else delete propConfig.join; + }); + + const loadTextCb = checkboxWithLabel("load text", !!propConfig.load_text, (checked) => { + propConfig.load_text = checked; + joinSelect.disabled = !checked; + if (!checked) { delete propConfig.join; joinSelect.value = ""; } + }); + + const joinWrap = document.createElement("span"); + joinWrap.style.cssText = "font-size:13px; display:flex; align-items:center; gap:4px;"; + const joinLabel = document.createElement("span"); + joinLabel.textContent = "join:"; + joinLabel.className = "hint"; + joinWrap.append(joinLabel, joinSelect); + + row.append(includeCb, expandCb, loadTextCb, joinWrap); + + const container = document.createElement("div"); + container.append(row, subPanel); + return container; +} + +// One @type heading — a checkbox (select this type as a table) plus a +// disclosure toggle unrolling to its properties. `onSelectionChange` redraws +// the whole list, since selecting/deselecting moves the type between +// config.tables/config.potential_tables — simplest to just re-render rather +// than track two possible positions for the same row. +function renderTypeRow(config, type, onSelectionChange) { + const entry = tableEntry(config, type); + const properties = entry.properties || {}; + + const wrap = document.createElement("div"); + wrap.style.cssText = "border-bottom:1px solid var(--border); padding:6px 0;"; + + const head = document.createElement("div"); + head.style.cssText = "display:flex; align-items:center; gap:8px;"; + + const selectCb = document.createElement("input"); + selectCb.type = "checkbox"; + selectCb.checked = isSelected(config, type); + selectCb.addEventListener("change", () => { + setTableSelected(config, type, selectCb.checked); + onSelectionChange(); + }); + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "secondary"; + toggle.style.cssText = "padding:2px 8px; font-size:12px; line-height:1.4;"; + toggle.textContent = "▸"; + + const summary = document.createElement("span"); + summary.style.fontWeight = "600"; + + function updateSummary() { + const { included, total } = includedCount(properties); + summary.textContent = `${type} — ${included}/${total} propert${total === 1 ? "y" : "ies"} shown`; + } + updateSummary(); + + head.append(selectCb, toggle, summary); + wrap.appendChild(head); + + const propsPanel = document.createElement("div"); + propsPanel.style.cssText = "margin:8px 0 4px 28px; display:none;"; + wrap.appendChild(propsPanel); + + let built = false; + toggle.addEventListener("click", () => { + const expanded = propsPanel.style.display !== "none"; + propsPanel.style.display = expanded ? "none" : "block"; + toggle.textContent = expanded ? "▸" : "▾"; + if (!expanded && !built) { + built = true; + for (const propName of Object.keys(properties).sort()) { + propsPanel.appendChild(renderPropertyRow(properties, propName, updateSummary)); + } + } + }); + + return wrap; +} + +// Opens the editor and resolves to the edited config, or null if dismissed +// without an explicit Save/Cancel choice — the caller (index.js) treats +// null as "no change was confirmed", not "clear everything". +export async function openConfigTreeEditor({ config, openModal }) { + const working = cloneConfig(config); + + return openModal({ + title: "Configure RO-Crate tables", + onDismiss: () => null, + render(body, close) { + const intro = document.createElement("p"); + intro.textContent = "Tick a type to export it as a table. Expand a type to choose which properties become columns, and whether each one is expanded, has its file text loaded, or joined as CSV rows."; + body.appendChild(intro); + + const listWrap = document.createElement("div"); + listWrap.style.cssText = "max-height:420px; overflow-y:auto; border:1px solid var(--border); border-radius:8px; padding:4px 12px; margin-bottom:16px;"; + body.appendChild(listWrap); + + function renderList() { + listWrap.replaceChildren(); + const types = allTypeNames(working); + if (!types.length) { + const empty = document.createElement("p"); + empty.className = "hint"; + empty.textContent = "No entity types were found in this crate."; + listWrap.appendChild(empty); + return; + } + for (const type of types) { + listWrap.appendChild(renderTypeRow(working, type, renderList)); + } + } + renderList(); + + const actions = document.createElement("div"); + actions.className = "actions"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; cancelBtn.className = "secondary"; + cancelBtn.textContent = "Cancel"; + cancelBtn.addEventListener("click", () => close(null)); + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.textContent = "Save configuration"; + saveBtn.addEventListener("click", () => close(working)); + actions.append(cancelBtn, saveBtn); + body.appendChild(actions); + }, + }); +} diff --git a/src/crate2tables/discover.js b/src/crate2tables/discover.js new file mode 100644 index 0000000..1658b65 --- /dev/null +++ b/src/crate2tables/discover.js @@ -0,0 +1,52 @@ +// The "inspect the crate, merge onto whatever config already exists" step, +// factored out so both the build-time hook (index.js) and the standalone +// "Configure tables…" action share exactly one code path rather than two +// that could drift apart. +import { inspectCrate, mergeDiscovered, discoverExpandedProperties } from "roctable/lib/inspect.js"; +import { defaultConfig } from "roctable/lib/config.js"; + +// LDAC's own convention for "this entity's text content": ldac:mainText, +// falling back to indexableText when a type has no mainText property at +// all. A person configuring tables for the first time shouldn't have to +// already know that to get a useful default — this seeds load_text on +// whichever candidate the type actually has, the first time (and only the +// first time) that property is seen. +const MAIN_TEXT_TIER_1 = ["ldac:mainText", "mainText"]; +const MAIN_TEXT_TIER_2 = ["ldac:indexableText", "indexableText"]; + +// "First time seen" has to mean "wasn't in the config that existed before +// this call" — not "looks like the default shape after merging", since a +// person deliberately declining the property (include:false, and nothing +// else) is byte-for-byte the same shape mergeDiscovered gives a genuinely +// new one. Checking presence in the pre-merge config is the only way to +// tell "never decided" from "decided, and decided false" apart. +function propertyAlreadyKnown(existingConfig, type, propName) { + if (!existingConfig) return false; + const entry = existingConfig.tables?.[type] || existingConfig.potential_tables?.[type]; + return !!entry?.properties && propName in entry.properties; +} + +function applyMainTextDefaults(config, existingConfig) { + for (const bucket of [config.tables, config.potential_tables]) { + for (const [type, target] of Object.entries(bucket || {})) { + const properties = target.properties || {}; + const candidate = MAIN_TEXT_TIER_1.find((name) => name in properties) + || MAIN_TEXT_TIER_2.find((name) => name in properties); + if (!candidate) continue; + if (!propertyAlreadyKnown(existingConfig, type, candidate)) { + properties[candidate] = { include: true, load_text: true }; + } + } + } +} + +// crate: an ro-crate ROCrate instance (from ctx.crate mid-build, or +// loadCrateFromJson(existingCrateJson) for the standalone action). +// existingConfig: whatever config already exists (folder/upload), or null. +export function discoverConfig(crate, existingConfig) { + const discovered = inspectCrate(crate); + let config = mergeDiscovered(existingConfig || defaultConfig(), discovered); + applyMainTextDefaults(config, existingConfig); + config = discoverExpandedProperties(crate, config); + return config; +} diff --git a/src/crate2tables/index.js b/src/crate2tables/index.js index b1fbdb9..943555b 100644 --- a/src/crate2tables/index.js +++ b/src/crate2tables/index.js @@ -1,43 +1,47 @@ // Exports a built RO-Crate as one CSV per configured @type, using roctable -// (https://github.com/ptsefton/roctable) — a WIP, git-installed library that -// flattens an `ro-crate` ROCrate graph into tables according to a JSON -// config. chaos2crate's own crate.js builds its ROCrate with the same -// `ro-crate` package version roctable depends on, so ctx.crate can be handed -// straight to roctable's crate-walking functions with no adapter needed. +// (https://github.com/ptsefton/roctable). chaos2crate's own crate.js builds +// its ROCrate with the same `ro-crate` package version roctable depends on, +// so ctx.crate can be handed straight to roctable's crate-walking functions +// with no adapter needed. // -// roctable's own CLI is a two-step workflow: `roctable inspect` discovers -// every @type/property in a crate and writes/updates a config (new fields -// default to unselected, existing choices are preserved); `roctable csv` -// then extracts tables for whatever the config's "tables" section selects. -// This plugin runs the same two steps on every build instead of requiring a -// separate CLI pass: it always re-discovers against the current crate and -// rewrites crate2tables-config.json (non-destructively — see -// roctable/lib/inspect.js's mergeDiscovered), then extracts+writes CSV for -// whatever the config's "tables" section already selects. A first build -// against a fresh folder therefore selects nothing and only seeds the -// config; a person edits that file (moving a type from "potential_tables" to -// "tables", setting "include"/"expand"/"load_text" on its properties) and -// reruns the build to get output. See docs/crate2tables-spec.md. +// Config lives at _config/roctable/config.json and output at +// _outputs/roctable/ — chaos2crate issue #81's proposed standard per-plugin +// directories (_config// for standing configuration, _outputs// +// for disposable generated content), adopted here ahead of that becoming a +// repo-wide convention. Both are excluded from "Delete plugin output before +// rebuilding" on the chaos2crate side (SPEC.md), since _config/ specifically +// is meant to persist across builds, not be cleared like _outputs/. +// +// On a build with no existing config (nothing at _config/roctable/config.json +// and nothing uploaded), the build blocks on the tree editor +// (config-tree-ui.js) so a person picks tables/properties before anything is +// extracted — the config is then written and that same build proceeds to +// extract+write CSVs from the just-confirmed selection. Once a config +// exists, later builds use it directly with no prompt; "Configure tables…" +// (an action tile, see optionSchema below) reopens the same editor on +// demand, independent of a build. // // load_text (roctable's "read this property's referenced file into the row" -// feature) reads through an injected fileReader (ptsefton/roctable#1) rather +// feature) reads through an injected fileReader (ptsefton/roctable#2) rather // than roctable's own Node-fs default — browserFileReader below wraps // chaos2crate's readFileTextFromDirectory, which already returns null for // "not found", matching what extractTables' loadText expects from a reader. -import { inspectCrate, mergeDiscovered, discoverExpandedProperties } from "roctable/lib/inspect.js"; import { extractTables } from "roctable/lib/extract.js"; import { tablesToCsvStrings } from "roctable/lib/csv.js"; -import { defaultConfig } from "roctable/lib/config.js"; +import { discoverConfig } from "./discover.js"; +import { openConfigTreeEditor } from "./config-tree-ui.js"; -const CONFIG_FILE = "crate2tables-config.json"; -const OUTPUT_DIR = "crate2tables-output"; +const CONFIG_DIR = "_config/roctable"; +const CONFIG_FILE = `${CONFIG_DIR}/config.json`; +const OUTPUT_DIR = "_outputs/roctable"; +const CRATE_FILE = "ro-crate-metadata.json"; // Hook names are literal strings and core chaos2crate functions arrive via // createPlugin(deps) — see this repo's README. -let readJsonFromFolder, writeFileAtPath, getFileHandleAtPath, readFileTextFromDirectory; +let readJsonFromFolder, writeFileAtPath, getFileHandleAtPath, readFileTextFromDirectory, loadCrateFromJson, openModal; export function createPlugin(deps) { - ({ readJsonFromFolder, writeFileAtPath, getFileHandleAtPath, readFileTextFromDirectory } = deps); + ({ readJsonFromFolder, writeFileAtPath, getFileHandleAtPath, readFileTextFromDirectory, loadCrateFromJson, openModal } = deps); return plugin; } @@ -49,21 +53,52 @@ async function existsAtPath(dirHandle, relativePath) { return !!(await getFileHandleAtPath(dirHandle, relativePath)); } +async function readConfigFromFolder(dirHandle) { + const text = await readFileTextFromDirectory(dirHandle, CONFIG_FILE); + if (text == null) return null; + try { return JSON.parse(text); } + catch (e) { throw new Error(`${CONFIG_FILE} is not valid JSON: ${e.message}`); } +} + const plugin = { name: "crate2tables", optionSchema: { key: "enableCrate2Tables", label: "Export RO-Crate tables", default: false, - hint: 'Flattens the crate into one CSV per entity type, using crate2tables-config.json — written to the folder on the first build with every discovered type/property, unselected. Move a type from "potential_tables" to "tables" and set "include": true on the properties you want, then rebuild. See docs/crate2tables-spec.md.', + hint: `Flattens the crate into one CSV per entity type. On the first build, choose which types/properties to export; the choice is saved to ${CONFIG_FILE} and reused on later builds. See docs/crate2tables-spec.md.`, children: [ + { + key: "crate2tablesConfigure", type: "action", label: "Configure tables…", + hint: "Review or change which types/properties become tables, without waiting for a build.", + run: async ({ dirHandle, log }) => { + if (!dirHandle) { log("crate2tables: pick a folder first.", "warn"); return; } + const crateJson = await readJsonFromFolder(dirHandle, CRATE_FILE); + if (!crateJson) { + log(`crate2tables: no ${CRATE_FILE} in this folder yet — build once first, then configure tables.`, "warn"); + return; + } + let config; + try { + const existingConfig = await readConfigFromFolder(dirHandle); + config = discoverConfig(loadCrateFromJson(crateJson), existingConfig); + } catch (e) { + log(`crate2tables: could not inspect the crate — ${e.message}`, "warn"); + return; + } + const edited = await openConfigTreeEditor({ config, openModal }); + if (edited === null) { log("crate2tables: configuration unchanged.", "muted"); return; } + await writeFileAtPath(dirHandle, CONFIG_FILE, JSON.stringify(edited, null, 2) + "\n"); + log(`crate2tables: saved ${CONFIG_FILE}. Rebuild to regenerate the CSV output.`, "ok"); + }, + }, { key: "crate2tablesConfigUpload", type: "file", label: "Table config (JSON)", accept: "application/json,.json", - hint: "Overrides crate2tables-config.json from the folder, if present." }, + hint: `Overrides ${CONFIG_FILE} from the folder, if present.` }, ], }, outputPaths: [ - { path: CONFIG_FILE, kind: "file" }, + { path: CONFIG_DIR, kind: "dir" }, { path: OUTPUT_DIR, kind: "dir" }, ], hooks: { @@ -79,23 +114,31 @@ const plugin = { catch (e) { throw new Error(`uploaded table config "${options.crate2tablesConfigUpload.name}" is not valid JSON: ${e.message}`); } configSource = `uploaded (${options.crate2tablesConfigUpload.name})`; } else { - const folderConfig = await readJsonFromFolder(dirHandle, CONFIG_FILE); + const folderConfig = await readConfigFromFolder(dirHandle); if (folderConfig) { existingConfig = folderConfig; configSource = CONFIG_FILE; } } let config; try { - config = discoverExpandedProperties(crate, mergeDiscovered(existingConfig || defaultConfig(), inspectCrate(crate))); + config = discoverConfig(crate, existingConfig); } catch (e) { log(`crate2tables: could not inspect the crate — ${e.message}`, "warn"); return; } + if (!existingConfig) { + log("crate2tables: no existing table configuration — opening the table picker.", "info"); + const edited = await openConfigTreeEditor({ config, openModal }); + if (edited === null) throw new Error("Build cancelled: table configuration was not confirmed."); + config = edited; + configSource = "configured just now"; + } + ctx.crate2tables = { config, configSource }; const tableNames = Object.keys(config.tables || {}); if (!tableNames.length) { - log(`crate2tables: no tables selected yet (config source: ${configSource}). Wrote every discovered type to ${CONFIG_FILE} under "potential_tables" — move the ones you want into "tables" and rebuild.`, "warn"); + log(`crate2tables: no tables selected (config source: ${configSource}). Use "Configure tables…" to pick some, or edit ${CONFIG_FILE} directly.`, "warn"); return; } From 907c6a02dc5bf2c9e583d6fcdc2cfec5ae80a485 Mon Sep 17 00:00:00 2001 From: Peter Sefton Date: Thu, 3 Sep 2026 07:58:58 +1000 Subject: [PATCH 2/3] Rename crate2tables plugin to roctable, matching its own slug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin already used "roctable" as its _config//_outputs directory slug (matching chaos2crate issue #81's own example) but was registered and referred to everywhere else as "crate2tables" — inconsistent with itself. Renamed throughout: src/crate2tables/ -> src/roctable/, name: "roctable", enableCrate2Tables -> enableRoctable, crate2tablesConfigure -> roctableConfigure, crate2tablesConfigUpload -> roctableConfigUpload. No behavioural change — re-verified end-to-end against the real F2F collection after the rename. --- README.md | 35 ++++----- index.js | 4 +- .../config-tree-ui.js | 2 +- src/{crate2tables => roctable}/discover.js | 0 src/{crate2tables => roctable}/index.js | 72 ++++++++++--------- 5 files changed, 58 insertions(+), 55 deletions(-) rename src/{crate2tables => roctable}/config-tree-ui.js (99%) rename src/{crate2tables => roctable}/discover.js (100%) rename src/{crate2tables => roctable}/index.js (69%) diff --git a/README.md b/README.md index cc8720b..f051759 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ handlers close over. Call it once, before the plugin's hooks can fire. | `file-format-identify` | `graphEntityById` (handed to `matcher.js`'s own `configure(deps)` on each dynamic import, for `getFileHandleAtPath`) | | `ca-data-prep` | `writeFileAtPath` | | `merge` | `readJsonFromFolder`, `graphEntityById` | -| `crate2tables` | `readJsonFromFolder`, `writeFileAtPath`, `getFileHandleAtPath`, `readFileTextFromDirectory`, `loadCrateFromJson` (lets "Configure tables…" inspect the folder's crate without a build running), `openModal` (the table-selection tree, `config-tree-ui.js`) | +| `roctable` | `readJsonFromFolder`, `writeFileAtPath`, `getFileHandleAtPath`, `readFileTextFromDirectory`, `loadCrateFromJson` (lets "Configure tables…" inspect the folder's crate without a build running), `openModal` (the table-selection tree, `config-tree-ui.js`) | | `validate-crate` | `loadMasp` | | `ro-crate-json-output` | `crateToJsonString`, `writeFile`, `fileExists` | | `ro-crate-xlsx-output` | `crateToXlsxBytes`, `writeFile`, `fileExists` | @@ -67,22 +67,23 @@ function itself, so `ro-crate-masp` (a heavy validator library) stays dynamically imported from chaos2crate's own tree instead of becoming a static import anywhere in this package. -`crate2tables` depends on [`roctable`](https://github.com/ptsefton/roctable), -a WIP library not yet on npm — installed as `"roctable": "file:../roctable"` -while both are under active development (swap to a `github:ptsefton/roctable` -git dependency, pinned to a commit, once roctable's own PR lands). It reuses -roctable's own crate-walking functions directly (`ctx.crate` is already an -`ro-crate` `ROCrate` instance, the same shape roctable expects) — including +The `roctable` plugin (`src/roctable/`) takes its name from the +[`roctable`](https://github.com/ptsefton/roctable) library it wraps — a WIP +library not yet on npm — installed as `"roctable": "file:../roctable"` while +both are under active development (swap to a `github:ptsefton/roctable` git +dependency, pinned to a commit, once roctable's own PR lands). It reuses the +library's own crate-walking functions directly (`ctx.crate` is already an +`ro-crate` `ROCrate` instance, the same shape it expects) — including `load_text`, via a `fileReader` this plugin injects -(`browserFileReader` in `src/crate2tables/index.js`, wrapping -`readFileTextFromDirectory`) rather than roctable's own Node-`fs`-based -default (see roctable's `lib/io.js` and its `SPEC.md` §9.0). Its config -load/save and CSV file writing stay this plugin's own job either way — -roctable's `lib/config.js`/`lib/csv.js` file I/O is Node-`fs`-only and simply -isn't called from here; see `chaos2crate/docs/crate2tables-spec.md`. - -`crate2tables` is split across three files: `index.js` (the plugin itself — -hooks, the `optionSchema`, the `crate2tablesConfigure` action), `discover.js` +(`browserFileReader` in `src/roctable/index.js`, wrapping +`readFileTextFromDirectory`) rather than the library's own Node-`fs`-based +default (see its `lib/io.js` and `SPEC.md` §9.0). Its config load/save and +CSV file writing stay this plugin's own job either way — the library's +`lib/config.js`/`lib/csv.js` file I/O is Node-`fs`-only and simply isn't +called from here; see `chaos2crate/docs/roctable-spec.md`. + +The plugin is split across three files: `index.js` (the plugin itself — +hooks, the `optionSchema`, the `roctableConfigure` action), `discover.js` (inspect the crate and merge onto whatever config already exists — the one code path both the build-time hook and the standalone action call, plus the `ldac:mainText`/`indexableText` default-seeding rule), and `config-tree-ui.js` @@ -155,7 +156,7 @@ Rules of thumb: chaos2crate's "Delete plugin output before rebuilding" skips deleting it** — those two are meant to persist across builds (standing configuration, changed-file backups), unlike `_outputs//`, which is exactly the - disposable generated content that setting exists to clear. `crate2tables` + disposable generated content that setting exists to clear. `roctable` is the first plugin here to use this: config at `_config/roctable/`, CSVs at `_outputs/roctable/`. diff --git a/index.js b/index.js index 27f443b..21e420e 100644 --- a/index.js +++ b/index.js @@ -13,11 +13,11 @@ import { createPlugin as createFileFormatIdentify } from "./src/file-format-iden import { createPlugin as createCaDataPrep } from "./src/ca-data-prep/index.js"; import { createPlugin as createChatExport } from "./src/chat-export/index.js"; import { createPlugin as createMerge } from "./src/merge/index.js"; +import { createPlugin as createRoctable } from "./src/roctable/index.js"; import { createPlugin as createValidateCrate } from "./src/validate-crate/index.js"; import { createPlugin as createJsonOutput } from "./src/ro-crate-json-output/index.js"; import { createPlugin as createXlsxOutput } from "./src/ro-crate-xlsx-output/index.js"; import { createPlugin as createHtmlOutput } from "./src/ro-crate-html-output/index.js"; -import { createPlugin as createCrate2Tables } from "./src/crate2tables/index.js"; import { createPlugin as createGenericInput } from "./src/generic-input/index.js"; import { createPlugin as createDocxInput } from "./src/docx-input/index.js"; @@ -34,7 +34,7 @@ export const REGISTRY = { "ca-data-prep": createCaDataPrep, "chat-export": createChatExport, "merge": createMerge, - "crate2tables": createCrate2Tables, + "roctable": createRoctable, "validate-crate": createValidateCrate, "ro-crate-json-output": createJsonOutput, "ro-crate-xlsx-output": createXlsxOutput, diff --git a/src/crate2tables/config-tree-ui.js b/src/roctable/config-tree-ui.js similarity index 99% rename from src/crate2tables/config-tree-ui.js rename to src/roctable/config-tree-ui.js index a43dce6..442e6c8 100644 --- a/src/crate2tables/config-tree-ui.js +++ b/src/roctable/config-tree-ui.js @@ -56,7 +56,7 @@ function includedCount(properties) { } // A one-hop expanded sub-property row: include only (rename exists in the -// config format but has no UI control yet — see docs/crate2tables-spec.md). +// config format but has no UI control yet — see docs/roctable-spec.md). function renderSubPropertyRow(properties, name) { const propConfig = properties[name]; const wrap = document.createElement("div"); diff --git a/src/crate2tables/discover.js b/src/roctable/discover.js similarity index 100% rename from src/crate2tables/discover.js rename to src/roctable/discover.js diff --git a/src/crate2tables/index.js b/src/roctable/index.js similarity index 69% rename from src/crate2tables/index.js rename to src/roctable/index.js index 943555b..9417792 100644 --- a/src/crate2tables/index.js +++ b/src/roctable/index.js @@ -1,8 +1,9 @@ -// Exports a built RO-Crate as one CSV per configured @type, using roctable -// (https://github.com/ptsefton/roctable). chaos2crate's own crate.js builds -// its ROCrate with the same `ro-crate` package version roctable depends on, -// so ctx.crate can be handed straight to roctable's crate-walking functions -// with no adapter needed. +// Exports a built RO-Crate as one CSV per configured @type, using the +// roctable library (https://github.com/ptsefton/roctable) — this plugin +// takes its name from it. chaos2crate's own crate.js builds its ROCrate +// with the same `ro-crate` package version the roctable library depends on, +// so ctx.crate can be handed straight to its crate-walking functions with +// no adapter needed. // // Config lives at _config/roctable/config.json and output at // _outputs/roctable/ — chaos2crate issue #81's proposed standard per-plugin @@ -21,11 +22,12 @@ // (an action tile, see optionSchema below) reopens the same editor on // demand, independent of a build. // -// load_text (roctable's "read this property's referenced file into the row" -// feature) reads through an injected fileReader (ptsefton/roctable#2) rather -// than roctable's own Node-fs default — browserFileReader below wraps -// chaos2crate's readFileTextFromDirectory, which already returns null for -// "not found", matching what extractTables' loadText expects from a reader. +// load_text (the roctable library's "read this property's referenced file +// into the row" feature) reads through an injected fileReader +// (ptsefton/roctable#2) rather than its own Node-fs default — +// browserFileReader below wraps chaos2crate's readFileTextFromDirectory, +// which already returns null for "not found", matching what extractTables' +// loadText expects from a reader. import { extractTables } from "roctable/lib/extract.js"; import { tablesToCsvStrings } from "roctable/lib/csv.js"; import { discoverConfig } from "./discover.js"; @@ -61,21 +63,21 @@ async function readConfigFromFolder(dirHandle) { } const plugin = { - name: "crate2tables", + name: "roctable", optionSchema: { - key: "enableCrate2Tables", + key: "enableRoctable", label: "Export RO-Crate tables", default: false, - hint: `Flattens the crate into one CSV per entity type. On the first build, choose which types/properties to export; the choice is saved to ${CONFIG_FILE} and reused on later builds. See docs/crate2tables-spec.md.`, + hint: `Flattens the crate into one CSV per entity type. On the first build, choose which types/properties to export; the choice is saved to ${CONFIG_FILE} and reused on later builds. See docs/roctable-spec.md.`, children: [ { - key: "crate2tablesConfigure", type: "action", label: "Configure tables…", + key: "roctableConfigure", type: "action", label: "Configure tables…", hint: "Review or change which types/properties become tables, without waiting for a build.", run: async ({ dirHandle, log }) => { - if (!dirHandle) { log("crate2tables: pick a folder first.", "warn"); return; } + if (!dirHandle) { log("roctable: pick a folder first.", "warn"); return; } const crateJson = await readJsonFromFolder(dirHandle, CRATE_FILE); if (!crateJson) { - log(`crate2tables: no ${CRATE_FILE} in this folder yet — build once first, then configure tables.`, "warn"); + log(`roctable: no ${CRATE_FILE} in this folder yet — build once first, then configure tables.`, "warn"); return; } let config; @@ -83,16 +85,16 @@ const plugin = { const existingConfig = await readConfigFromFolder(dirHandle); config = discoverConfig(loadCrateFromJson(crateJson), existingConfig); } catch (e) { - log(`crate2tables: could not inspect the crate — ${e.message}`, "warn"); + log(`roctable: could not inspect the crate — ${e.message}`, "warn"); return; } const edited = await openConfigTreeEditor({ config, openModal }); - if (edited === null) { log("crate2tables: configuration unchanged.", "muted"); return; } + if (edited === null) { log("roctable: configuration unchanged.", "muted"); return; } await writeFileAtPath(dirHandle, CONFIG_FILE, JSON.stringify(edited, null, 2) + "\n"); - log(`crate2tables: saved ${CONFIG_FILE}. Rebuild to regenerate the CSV output.`, "ok"); + log(`roctable: saved ${CONFIG_FILE}. Rebuild to regenerate the CSV output.`, "ok"); }, }, - { key: "crate2tablesConfigUpload", type: "file", label: "Table config (JSON)", + { key: "roctableConfigUpload", type: "file", label: "Table config (JSON)", accept: "application/json,.json", hint: `Overrides ${CONFIG_FILE} from the folder, if present.` }, ], @@ -103,16 +105,16 @@ const plugin = { ], hooks: { "crate:built": async (ctx) => { - if (!ctx.options.enableCrate2Tables) return; + if (!ctx.options.enableRoctable) return; const { crate, dirHandle, options, log } = ctx; let existingConfig = null; let configSource = "none — starting fresh"; - if (options.crate2tablesConfigUpload) { - const text = await options.crate2tablesConfigUpload.file.text(); + if (options.roctableConfigUpload) { + const text = await options.roctableConfigUpload.file.text(); try { existingConfig = JSON.parse(text); } - catch (e) { throw new Error(`uploaded table config "${options.crate2tablesConfigUpload.name}" is not valid JSON: ${e.message}`); } - configSource = `uploaded (${options.crate2tablesConfigUpload.name})`; + catch (e) { throw new Error(`uploaded table config "${options.roctableConfigUpload.name}" is not valid JSON: ${e.message}`); } + configSource = `uploaded (${options.roctableConfigUpload.name})`; } else { const folderConfig = await readConfigFromFolder(dirHandle); if (folderConfig) { existingConfig = folderConfig; configSource = CONFIG_FILE; } @@ -122,39 +124,39 @@ const plugin = { try { config = discoverConfig(crate, existingConfig); } catch (e) { - log(`crate2tables: could not inspect the crate — ${e.message}`, "warn"); + log(`roctable: could not inspect the crate — ${e.message}`, "warn"); return; } if (!existingConfig) { - log("crate2tables: no existing table configuration — opening the table picker.", "info"); + log("roctable: no existing table configuration — opening the table picker.", "info"); const edited = await openConfigTreeEditor({ config, openModal }); if (edited === null) throw new Error("Build cancelled: table configuration was not confirmed."); config = edited; configSource = "configured just now"; } - ctx.crate2tables = { config, configSource }; + ctx.roctable = { config, configSource }; const tableNames = Object.keys(config.tables || {}); if (!tableNames.length) { - log(`crate2tables: no tables selected (config source: ${configSource}). Use "Configure tables…" to pick some, or edit ${CONFIG_FILE} directly.`, "warn"); + log(`roctable: no tables selected (config source: ${configSource}). Use "Configure tables…" to pick some, or edit ${CONFIG_FILE} directly.`, "warn"); return; } try { const data = await extractTables(crate, config, { fileReader: browserFileReader(dirHandle) }); - ctx.crate2tables.csv = tablesToCsvStrings(data); - log(`crate2tables: built ${tableNames.length} table(s) — ${tableNames.join(", ")}.`, "ok"); + ctx.roctable.csv = tablesToCsvStrings(data); + log(`roctable: built ${tableNames.length} table(s) — ${tableNames.join(", ")}.`, "ok"); } catch (e) { - log(`crate2tables: failed to extract tables — ${e.message}`, "warn"); + log(`roctable: failed to extract tables — ${e.message}`, "warn"); } }, "output:write": async (ctx) => { - if (!ctx.options.enableCrate2Tables || !ctx.crate2tables) return; + if (!ctx.options.enableRoctable || !ctx.roctable) return; const { dirHandle, options, log } = ctx; - const { config, csv } = ctx.crate2tables; + const { config, csv } = ctx.roctable; // Non-destructive by construction (mergeDiscovered only ever adds // newly-seen types/properties, unselected — see roctable/lib/inspect.js), @@ -173,7 +175,7 @@ const plugin = { log(`${path} exists and overwrite is off — skipped.`, "warn"); } } - if (written) log(`crate2tables: wrote ${written} CSV file(s) to ${OUTPUT_DIR}/.`, "ok"); + if (written) log(`roctable: wrote ${written} CSV file(s) to ${OUTPUT_DIR}/.`, "ok"); }, }, }; From 77d7ee298b469b1aeee834a68e384ba9d552d442 Mon Sep 17 00:00:00 2001 From: Peter Sefton Date: Thu, 3 Sep 2026 11:11:30 +1000 Subject: [PATCH 3/3] Fix: expand never discovered sub-properties for an unselected table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit roctable's own discoverExpandedProperties (lib/inspect.js) only walks config.tables, silently skipping any type still in potential_tables — verified against its actual source. The tree editor lets a person check "expand" on a potential (not yet ticked) table's property, so that setting was getting saved but never actually resolved into sub-properties, no matter how many rebuilds ran, since the function never even looked at it. discoverExpandedPropertiesForAllTables (discover.js) runs the same trusted function twice — once normally, once with tables/potential_tables swapped — rather than reimplementing its crate-walking logic. Verified against a real F2F RepositoryObject, still unselected, with contentLocation (a genuine Place reference) checked for expansion: sub-properties now populate on the very next rebuild. Also: a property whose expansion was tried and genuinely found nothing (a plain-text value, not an @id reference — e.g. F2F's own `author` field, a bare name rather than a Person link) now gets its own message distinguishing "tried, nothing to expand" from "not tried yet", instead of one hint implying a rebuild might eventually help when it never will. Also widens the config editor modal — the host's shared .modal caps out at 420px, sized for a short confirmation, not a four-control-per-row property table. --- src/roctable/config-tree-ui.js | 43 +++++++++++++++++++++++++++++----- src/roctable/discover.js | 21 ++++++++++++++++- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/roctable/config-tree-ui.js b/src/roctable/config-tree-ui.js index 442e6c8..cfca3b9 100644 --- a/src/roctable/config-tree-ui.js +++ b/src/roctable/config-tree-ui.js @@ -78,17 +78,30 @@ function renderPropertyRow(properties, name, onIncludeChange) { const subPanel = document.createElement("div"); subPanel.style.cssText = `margin:2px 0 6px 26px; ${propConfig.expand ? "" : "display:none;"}`; + // discoverExpandedProperties (roctable) only ever adds a `properties` key + // once it's actually walked the crate for this property — an *absent* key + // means "not tried yet" (still needs a discovery pass, i.e. a rebuild or + // reopening "Configure tables…"); a `properties` key present but *empty* + // means it was tried and found nothing to expand, which happens whenever + // the property's actual values aren't @id references at all (e.g. a plain + // text value like a bare author name) — expanding it can never produce + // anything, no amount of rebuilding will change that. Collapsing both + // into one "come back later" message would misrepresent the second case + // as a timing issue instead of a data fact. function renderSubPanel() { subPanel.replaceChildren(); - if (propConfig.properties && Object.keys(propConfig.properties).length) { + const hint = document.createElement("div"); + hint.className = "hint"; + if (!propConfig.properties) { + hint.textContent = "Sub-properties appear here after the next build re-discovers this expansion."; + subPanel.appendChild(hint); + } else if (!Object.keys(propConfig.properties).length) { + hint.textContent = "No reference values found for this property in the crate — nothing to expand."; + subPanel.appendChild(hint); + } else { for (const subName of Object.keys(propConfig.properties).sort()) { subPanel.appendChild(renderSubPropertyRow(propConfig.properties, subName)); } - } else { - const hint = document.createElement("div"); - hint.className = "hint"; - hint.textContent = "Sub-properties appear here after the next build re-discovers this expansion."; - subPanel.appendChild(hint); } } renderSubPanel(); @@ -198,14 +211,32 @@ function renderTypeRow(config, type, onSelectionChange) { return wrap; } +// The host's own .modal caps out at max-width:440px (index.html's shared +// stylesheet) — sized for a short confirmation, not a multi-column property +// table with four controls per row. openModal's modalClassName adds a class +// to that same .modal element but supplies no rule of its own, so this +// injects one override, once per page load, scoped to that class — more +// specific than the bare .modal rule, so it wins regardless of stylesheet +// order, without editing index.html's shared CSS for one plugin's modal. +let stylesInjected = false; +function ensureWideModalStyle() { + if (stylesInjected) return; + stylesInjected = true; + const style = document.createElement("style"); + style.textContent = ".modal.roctable-config-modal { max-width: min(900px, 94vw); }"; + document.head.appendChild(style); +} + // Opens the editor and resolves to the edited config, or null if dismissed // without an explicit Save/Cancel choice — the caller (index.js) treats // null as "no change was confirmed", not "clear everything". export async function openConfigTreeEditor({ config, openModal }) { const working = cloneConfig(config); + ensureWideModalStyle(); return openModal({ title: "Configure RO-Crate tables", + modalClassName: "roctable-config-modal", onDismiss: () => null, render(body, close) { const intro = document.createElement("p"); diff --git a/src/roctable/discover.js b/src/roctable/discover.js index 1658b65..ca9ae01 100644 --- a/src/roctable/discover.js +++ b/src/roctable/discover.js @@ -40,6 +40,25 @@ function applyMainTextDefaults(config, existingConfig) { } } +// roctable's own discoverExpandedProperties (lib/inspect.js) only walks +// config.tables — a type still sitting in potential_tables (not yet ticked +// as a selected table in the tree editor) never gets an expanded property's +// sub-properties discovered, no matter how many times a build runs, since +// the function silently skips it rather than erroring. But the tree editor +// lets a person check "expand" on a potential table's property before +// deciding whether to select the table at all — a reasonable thing to want +// to preview — so this has to work for both buckets. Rather than +// reimplementing that walk (crate-entity iteration, one-hop dereferencing, +// STRUCTURAL_PROPS filtering), run the same trusted function twice with the +// two buckets swapped, so potential_tables gets identical treatment via the +// exact same code path tables already gets. +function discoverExpandedPropertiesForAllTables(crate, config) { + const afterTables = discoverExpandedProperties(crate, config); + const swapped = { ...afterTables, tables: afterTables.potential_tables, potential_tables: afterTables.tables }; + const afterBoth = discoverExpandedProperties(crate, swapped); + return { ...afterBoth, tables: afterBoth.potential_tables, potential_tables: afterBoth.tables }; +} + // crate: an ro-crate ROCrate instance (from ctx.crate mid-build, or // loadCrateFromJson(existingCrateJson) for the standalone action). // existingConfig: whatever config already exists (folder/upload), or null. @@ -47,6 +66,6 @@ export function discoverConfig(crate, existingConfig) { const discovered = inspectCrate(crate); let config = mergeDiscovered(existingConfig || defaultConfig(), discovered); applyMainTextDefaults(config, existingConfig); - config = discoverExpandedProperties(crate, config); + config = discoverExpandedPropertiesForAllTables(crate, config); return config; }