ZDDC/zddc/internal/handler/tables.html
ZDDC e6d9966593 refactor(tables): in-dir convention + unified table+form HTML bundle
Two intertwined refactors that share too many files to split cleanly.
Both are described separately below.

PART 1 — in-dir convention for table+form spec files

Old layout had the spec at the parent and rows in a child:

    archive/<party>/
      mdl.table.yaml         spec
      mdl.form.yaml          row-edit form
      mdl/                   rows-dir
        row-001.yaml ...

URLs were /<dir>/mdl.table.html and /<dir>/mdl.form.html. Copying
mdl/ elsewhere lost the spec and form because they lived next door.

New layout collapses everything into the rows-dir:

    archive/<party>/mdl/      self-contained
      table.yaml              spec
      form.yaml               row-edit form
      row-001.yaml ...        rows

URLs become /<dir>/mdl/table.html and /<dir>/mdl/form.html. The
"copying-the-folder-takes-everything" property the user asked for
falls out by construction; the row-edit URL /<dir>/<id>.yaml.html
keeps the same shape (spec is now in the same dir, not the
grandparent).

Server changes:

- internal/handler/tablehandler.go RecognizeTableRequest fires on
  /<dir>/table.html when <dir>/table.yaml exists. The .zddc.tables
  alias map is gone — pure presence-based discovery now matches
  the form system's existing convention. Default-MDL fallback at
  archive/<party>/mdl/ stays for the virgin-archive case (the
  rows-dir need not exist on disk; the URL renders fully virtually).

- internal/handler/formhandler.go RecognizeFormRequest fires on
  /<dir>/form.html and /<dir>/<id>.yaml.html with spec at
  <dir>/form.yaml. specEligible accepts on-disk files OR the
  default-MDL virtual path so an empty mdl/ dir still surfaces the
  add-row form.

- internal/handler/tablehandler.go IsDefaultMdlSpec moves to
  serving archive/<party>/mdl/{table,form}.yaml (5 segments after
  ZDDC_ROOT). New isAtArchivePartyMdlLevel predicate; new
  isAtArchivePartyMdlDir for directory-based recognition. New
  IsDefaultMdlSpecAbs accessor for callers that hold an abs path
  rather than a URL (formhandler).

- internal/handler/formhandler.go loadFormSpec(fsRoot, path) falls
  back to embedded default-MDL bytes when os.ReadFile returns
  NotExist AND the path matches the archive-party-mdl shape. Three
  call sites updated to pass cfg.Root.

- internal/handler/formhandler.go serveFormCreate writes
  submissions to filepath.Dir(req.SpecPath) — the spec, the form,
  and rows all live in one directory. The submissionsDir creation
  is idempotent (MkdirAll); cascade falls back one level for ACL
  evaluation when the dir hasn't been materialized yet.

- internal/handler/tablehandler.go tableRowsRedirect now points at
  /<dir>/table.html (was /<dir>.table.html) when the directory
  request maps to a recognized table.

- cmd/zddc-server/main.go dispatch synth flips from
  urlPath + ".table.html" to urlPath + "/table.html" for the
  no-trailing-slash → tables-app routing.

- internal/apps/availability.go DefaultAppAt comment clarified
  that the dir at archive/<party>/mdl/ IS the table (not a child).

Client changes:

- tables/js/context.js walkServer fetches <currentdir>/table.yaml
  directly — no .zddc walk for table declarations. Rows are every
  *.yaml in current dir EXCLUDING table.yaml and form.yaml. The
  .zddc fetch-for-aliases is gated on file:// (online mode 404s
  on .zddc reads via the dispatcher's reserve guard, so skipping
  the request avoids browser console noise).

- tables/js/main.js add-row button links to relative form.html
  (same dir).

- tables/js/render.js + filters.js: every column's autofilter is
  uniformly a text-contains input, even enum columns — keeps the
  filter row visually consistent and doesn't constrain users to
  the enum vocabulary.

PART 2 — unified table+form HTML bundle

The form-render and table-render code paths share field schemas,
the cell editor for excel-mode IS a form widget, and the form
system's POST-back / validation already exists. Combining the two
HTMLs eliminates duplicating jsyaml/jsonschema/theme/source-
detection/.zddc-parsing across two single-file tools.

- tables/template.html grows two top-level mode containers:
  #table-mode (toolbar + sortable table) and #form-mode (form +
  submit button). Both hidden at parse time; the dispatcher
  unhides one. The shared #form-context placeholder was added
  here so the server's existing injectFormContext target
  resolves.

- tables/js/mode.js (new) sets window.zddcMode synchronously
  based on URL pattern: /form.html or /<id>.yaml.html → form,
  /table.html → table, else inline-context fallback for
  file:// (whichever context blob is non-empty wins). Unhides
  the matching container at DOMContentLoaded.

- tables/js/main.js init() and form/js/main.js boot() each guard
  early when mode isn't theirs. Both apps live on different
  globals (window.tablesApp vs window.formApp) so module
  registration doesn't collide.

- form/js/main.js title write falls back from #form-title to
  #table-title (the unified bundle's shared header element)
  when the dedicated id isn't present.

- tables/build.sh concatenates form modules (widgets, render,
  object, array, errors, post, serialize, util) and form CSS.
  No new external deps. Bundle grows from ~95KB to ~120KB.

- internal/handler/formhandler.go drops the //go:embed form.html
  directive; serveFormRender now writes embeddedTablesHTML via
  a small formRenderHTML() accessor (var declared in
  tablehandler.go, same package). The embedded form.html file
  is removed.

- build script: cp form/dist/form.html → internal/handler/form.html
  step is gone (file no longer exists in the source tree). cp
  tables/dist/tables.html → internal/handler/tables.html now
  runs unconditionally rather than only on beta/stable cuts —
  the renderer is a fixed binary component and dev iteration
  needs the embedded copy refreshed every build. Channel-cascaded
  apps (internal/apps/embedded/) stay channel-gated as before.

- form/dist/form.html still builds for standalone offline-only
  use (downloadable from /releases/), but no longer goes into
  the binary.

Tests:

- internal/handler/tablehandler_test.go and formhandler_test.go
  rewritten for the in-dir layout. New test
  TestRecognizeFormRequest_DefaultMdlAtArchiveParty covers
  empty-form, create POST, re-edit row, and the negative cases
  (Working/, non-mdl name) where the fallback must NOT fire.

- internal/handler/directory_test.go updated for the new
  /<dir>/table.html redirect target.

- cmd/zddc-server/main_test.go TestDispatchSlashRouting Location
  expectation updated.

- tests/form-safety.spec.js loads tables/dist/tables.html
  (named form.html in the temp dir to trigger form-mode in the
  dispatcher) so it tests the same bytes the server returns.
  Title-element selector switches to #table-title.

- tests/tables.spec.js updates the status-filter test for the
  uniform text-input filter.

Docs:

- AGENTS.md form-data system rewrites the URL conventions and
  storage layout for in-dir; gains a Tables system section
  parallel to forms describing the self-contained-directory
  property; subfolder rules ("one table per folder by
  construction; subfolders allowed and silently ignored as rows
  — legitimate uses: nested sub-tables, per-row attachments,
  drafts, future history sidecars") so we don't re-derive this.

Not included (deferred):

- ACL gating on cell-level writes — not relevant until Phase 3.
- Editable cells UI — separate commit (Phase 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:15:26 -05:00

3487 lines
152 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Table</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
<style>
/* ==========================================================================
ZDDC Shared Base — single source of truth for tokens and primitives
Included first by every tool's build.sh via ../shared/base.css
========================================================================== */
/* ── CSS custom properties ────────────────────────────────────────────────── */
:root {
/* Brand / accent (matches zddc.varasys.io website --accent) */
--primary: #2a5a8a;
--primary-hover: #1d4060;
--primary-active: #163352;
--primary-light: #e8f0f7;
/* Semantic colours */
--success: #28a745;
--warning: #d97706;
--danger: #dc3545;
--info: #17a2b8;
/* Backgrounds */
--bg: #ffffff;
--bg-secondary: #f8f9fa;
--bg-hover: #f0f4f8;
--bg-selected: var(--primary-light);
/* Text */
--text: #212529;
--text-muted: #6c757d;
--text-light: #ffffff;
/* Borders */
--border: #dee2e6;
--border-dark: #adb5bd;
/* Shape */
--radius: 4px;
/* Typography */
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
}
/* ── Dark mode tokens ─────────────────────────────────────────────────────── */
/* Applied via: OS preference (auto) or [data-theme="dark"] on <html> */
/* The [data-theme="light"] selector locks light mode regardless of OS pref. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
}
/* Manual dark override — wins over media query */
[data-theme="dark"] {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
/* ── Reset ────────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Base document ────────────────────────────────────────────────────────── */
html, body {
height: 100%;
font-family: var(--font);
font-size: 16px;
line-height: 1.5;
color: var(--text);
background-color: var(--bg-secondary);
}
/* ── Typography ───────────────────────────────────────────────────────────── */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.2;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ── Utility ──────────────────────────────────────────────────────────────── */
.hidden {
display: none !important;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Scrollbars (webkit) ──────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 7px;
height: 7px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
/* ── Button primitive ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.85rem;
font-family: var(--font);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.4;
text-align: center;
text-decoration: none;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
border: 1px solid transparent;
border-radius: var(--radius);
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, color 0.15s;
background: var(--bg-secondary);
color: var(--text);
}
.btn:disabled,
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.btn:not(:disabled):hover {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
.btn:not(:disabled):active {
box-shadow: none;
}
/* Variants */
.btn-primary {
background: var(--primary);
color: var(--text-light);
border-color: var(--primary);
}
.btn-primary:not(:disabled):hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--text-light);
}
.btn-primary:not(:disabled):active {
background: var(--primary-active);
border-color: var(--primary-active);
}
.btn-secondary {
background: var(--bg);
color: var(--text);
border-color: var(--border);
}
.btn-secondary:not(:disabled):hover {
background: var(--bg-secondary);
}
/* Subdued / de-emphasized variant.
Used on the "Add Local Directory" button when a tool is operating
in server (online) mode — the local-dir affordance is still
available but visually quieter, since the typical user already
has the directory loaded from the server. */
.btn.btn--subtle {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
box-shadow: none;
font-weight: normal;
}
.btn.btn--subtle:not(:disabled):hover {
color: var(--text);
background: var(--bg-secondary);
}
.btn-success {
background: var(--success);
color: var(--text-light);
border-color: var(--success);
}
.btn-danger {
background: var(--danger);
color: var(--text-light);
border-color: var(--danger);
}
/* Sizes */
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.btn-lg {
padding: 0.6rem 1.4rem;
font-size: 1rem;
}
.btn-link {
background: transparent;
border-color: transparent;
color: var(--primary);
padding-left: 0;
padding-right: 0;
}
.btn-link:not(:disabled):hover {
text-decoration: underline;
box-shadow: none;
}
/* ── App header chrome ────────────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
/* Tool name inside the header */
.app-header__title {
font-size: 17px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.01em;
white-space: nowrap;
}
/* Brand logo — sits left of the title in every tool's app-header.
Self-contained: the SVG provides its own dark blue rounded background,
so no extra wrapper styling is needed. */
.app-header__logo {
width: 26px;
height: 26px;
flex-shrink: 0;
display: block;
}
/* ── Build timestamp ──────────────────────────────────────────────────────── */
.build-timestamp {
font-size: 0.55rem;
color: var(--text-muted);
opacity: 0.7;
font-weight: 300;
white-space: nowrap;
padding-top: 0.15rem;
}
/* Title + timestamp stacked vertically on the left side of the header */
.header-title-group {
display: flex;
flex-direction: column;
gap: 0;
line-height: 1;
}
/* ── Icon buttons (help, theme, refresh) ─────────────────────────────────── */
/* Square, centered — overrides the asymmetric text-button padding/line-height */
#help-btn,
#theme-btn,
#refreshHeaderBtn {
width: 2rem;
height: 2rem;
padding: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
#theme-btn,
#help-btn {
font-size: 1rem;
}
/* ── Help panel (shared slide-out drawer) ─────────────────────────────────── */
/* Used by all four tools. Toggle open/close via shared/help.js. */
.help-panel {
position: fixed;
top: 0;
right: 0;
width: min(420px, 85vw);
height: 100vh;
z-index: 1000;
background: var(--bg);
border-left: 1px solid var(--border);
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
}
.help-panel:not([hidden]) {
transform: translateX(0);
}
.help-panel[hidden] {
display: flex;
transform: translateX(100%);
pointer-events: none;
}
.help-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg);
}
.help-panel__title {
font-size: 1rem;
font-weight: 700;
color: var(--text);
margin: 0;
}
.help-panel__close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.35rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.help-panel__close:hover {
color: var(--text);
background: var(--bg-secondary);
}
.help-panel__body {
flex: 1;
overflow-y: auto;
padding: 1rem 1rem 2rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.help-panel__body h3 {
font-size: 0.95rem;
font-weight: 700;
margin: 1.25rem 0 0.35rem;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 0.15rem;
}
.help-panel__body h3:first-child {
margin-top: 0;
}
.help-panel__body h4 {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 1.25rem 0 0.3rem;
padding-left: 0.5rem;
border-left: 3px solid var(--border-dark);
color: var(--text-muted);
}
.help-panel__body p {
margin: 0 0 0.5rem;
}
.help-panel__body ol,
.help-panel__body ul {
padding-left: 1.5rem;
margin: 0.3rem 0 0.5rem;
}
.help-panel__body li {
margin-bottom: 0.3rem;
}
.help-panel__body dl {
margin: 0.3rem 0;
}
.help-panel__body dt {
font-weight: 600;
color: var(--text);
}
.help-panel__body dd {
margin: 0 0 0.5rem 1rem;
color: var(--text-muted);
}
.help-panel__body code {
font-family: var(--font-mono);
font-size: 0.8em;
background: var(--bg-secondary);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.help-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: var(--radius);
vertical-align: middle;
letter-spacing: 0.02em;
}
.help-badge--draft {
color: #2563eb;
background: #eff6ff;
}
.help-badge--published {
color: #7c3aed;
background: #f5f3ff;
}
/* Shrink main content when help panel is open */
body.help-open .app-header {
margin-right: min(420px, 85vw);
}
/* ── Column filter inputs (shared across archive, classifier, transmittal) ─── */
.column-filter {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 0.25rem;
padding: 0.2rem 0.4rem;
font-size: 0.8rem;
font-family: var(--font);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
transition: border-color 0.15s;
}
.column-filter:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 1px rgba(42, 90, 138, 0.35);
}
.column-filter::placeholder {
color: var(--text-muted);
}
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
.table-main {
padding: var(--spacing-md);
max-width: 100%;
}
.table-description {
margin: 0 0 var(--spacing-md);
color: var(--color-text-muted);
font-size: 0.95rem;
}
.table-status {
margin: 0 0 var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-warning, #fff8e6);
border: 1px solid var(--color-border, #d6cfa3);
border-radius: var(--radius-sm, 4px);
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-md);
margin: 0 0 var(--spacing-sm);
}
.table-toolbar__left,
.table-toolbar__right {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
#table-add-row {
text-decoration: none;
}
.table-rowcount {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.table-scroll {
overflow: auto;
max-height: calc(100vh - 200px);
border: 1px solid var(--color-border, #d8d8d8);
border-radius: var(--radius-sm, 4px);
}
.zddc-table {
border-collapse: collapse;
width: 100%;
font-size: 0.95rem;
}
.zddc-table thead {
position: sticky;
top: 0;
z-index: 2;
background: var(--color-bg-elevated, #f5f5f5);
}
.zddc-table__title-row .zddc-table__th {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
font-weight: 600;
border-bottom: 1px solid var(--color-border, #d8d8d8);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.zddc-table__title-row .zddc-table__th:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
}
.zddc-table__filter-row .zddc-table__filter-cell {
padding: 4px var(--spacing-sm);
border-bottom: 1px solid var(--color-border, #d8d8d8);
background: var(--color-bg-elevated, #f5f5f5);
}
.zddc-table__filter-text,
.zddc-table__filter-enum {
width: 100%;
box-sizing: border-box;
padding: 2px 4px;
font-size: 0.85rem;
border: 1px solid var(--color-border, #d0d0d0);
border-radius: 3px;
background: var(--color-bg, #fff);
color: var(--color-text, #111);
}
.zddc-table__filter-enum {
min-height: 1.8em;
}
.zddc-table__row:nth-child(even) {
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
}
.zddc-table__row--editable {
cursor: pointer;
}
.zddc-table__row--editable:hover {
background: var(--color-bg-hover, rgba(50, 100, 200, 0.08));
}
.zddc-table__row--readonly {
color: var(--color-text-muted);
}
.zddc-table__cell {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--color-border-soft, rgba(0, 0, 0, 0.06));
vertical-align: top;
}
.table-empty {
padding: var(--spacing-lg) var(--spacing-md);
text-align: center;
color: var(--color-text-muted);
font-style: italic;
}
/* form/ — ZDDC generic form renderer.
Pulls theme tokens from shared/base.css; only adds form-specific layout. */
.form-main {
max-width: 800px;
margin: 1.5rem auto;
padding: 0 1rem 4rem;
}
.form-status {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 4px;
border: 1px solid var(--color-border);
}
.form-status.is-error {
background: var(--color-bg-alt);
border-color: #c43;
color: #c43;
}
.form-status.is-success {
background: var(--color-bg-alt);
border-color: #283;
color: #283;
}
.form-root {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field__label {
font-weight: 600;
font-size: 0.95rem;
}
.form-field__label .required-mark {
color: #c43;
margin-left: 0.15rem;
}
.form-field__description {
font-size: 0.85rem;
color: var(--color-text-muted, #666);
}
.form-field__error {
font-size: 0.85rem;
color: #c43;
margin-top: 0.15rem;
}
.form-field__help {
font-size: 0.8rem;
color: var(--color-text-muted, #666);
font-style: italic;
}
.form-field__input,
.form-field__textarea,
.form-field__select {
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg, #fff);
color: var(--color-text, #111);
font: inherit;
width: 100%;
box-sizing: border-box;
}
.form-field__textarea {
min-height: 5em;
resize: vertical;
}
.form-field__input:focus,
.form-field__textarea:focus,
.form-field__select:focus {
outline: 2px solid var(--color-primary, #1e3a5f);
outline-offset: -1px;
}
.form-field--invalid .form-field__input,
.form-field--invalid .form-field__textarea,
.form-field--invalid .form-field__select {
border-color: #c43;
}
.form-field__radio-group,
.form-field__checkbox-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field__radio-group label,
.form-field__checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 400;
cursor: pointer;
}
.form-fieldset {
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-fieldset__legend {
font-weight: 600;
padding: 0 0.4rem;
}
.form-array {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-array__row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.5rem;
background: var(--color-bg-alt, #f6f6f8);
}
.form-array__row-body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-array__row-actions {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-array__add {
align-self: flex-start;
}
.form-actions {
margin-top: 1.5rem;
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-bg, #fff);
color: var(--color-text, #111);
cursor: pointer;
font: inherit;
}
.btn:hover {
background: var(--color-bg-alt, #f6f6f8);
}
.btn-primary {
background: var(--color-primary, #1e3a5f);
color: #fff;
border-color: var(--color-primary, #1e3a5f);
}
.btn-primary:hover {
filter: brightness(1.1);
}
.btn-small {
padding: 0.2rem 0.5rem;
font-size: 0.85rem;
}
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
</style>
</head>
<body>
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-09 14:14:07 · 2ce5336-dirty</span></span>
</div>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div>
</header>
<!-- Table mode: shown for /<dir>/table.html requests. -->
<main id="table-mode" class="table-main" hidden>
<div id="table-description" class="table-description" hidden></div>
<div id="table-status" class="table-status" hidden></div>
<div class="table-toolbar" id="table-toolbar">
<div class="table-toolbar__left">
<span id="table-rowcount" class="table-rowcount" aria-live="polite"></span>
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
</div>
<div class="table-toolbar__right">
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
</div>
</div>
<div class="table-scroll">
<table id="table-root" class="zddc-table" aria-describedby="table-description">
<thead></thead>
<tbody></tbody>
</table>
</div>
<div id="table-empty" class="table-empty" hidden>No rows match the current filters.</div>
</main>
<!-- Form mode: shown for /<dir>/form.html and /<dir>/<id>.yaml.html
requests. Same bundle ships both modes so a row's "+ Add row"
and click-to-edit reuse the table tool's spec, validator, and
file-IO instead of duplicating them in a separate form HTML. -->
<main id="form-mode" class="form-main" hidden>
<div id="form-status" class="form-status" hidden></div>
<form id="form-root" class="form-root" novalidate></form>
<div class="form-actions">
<button type="button" id="submit-btn" class="btn btn-primary">Submit</button>
</div>
</main>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Table</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is this table?</h3>
<p>Each row in this table is one YAML file in the source directory.
Tables are declared in <code>.zddc</code> via a
<code>tables:</code> map. The columns and row schema come from
a <code>*.table.yaml</code> spec file.</p>
<h3>Sorting</h3>
<p>Click a column header to sort by that column. Click again to
toggle direction. Shift-click another header to add a secondary
sort key.</p>
<h3>Filtering</h3>
<p>Type in the box under a column header to filter rows whose
value contains your text (case-insensitive). For columns with a
fixed enum, the box becomes a multi-select — leave it empty to
show every value.</p>
<h3>Editing a row</h3>
<p>Click a row to open its YAML in the form editor. Whether the
row is editable depends on the cascading <code>.zddc</code>
permissions for the row's path. Rows in <code>Issued</code> or
<code>Received</code> archives are read-only by design (WORM).</p>
<h3>Header buttons</h3>
<dl>
<dt>◐ Theme</dt>
<dd>Cycle auto / light / dark.</dd>
<dt>? Help</dt>
<dd>This panel. Press <kbd>Esc</kbd> to close.</dd>
</dl>
</div>
</aside>
<!--
Server injects the table context here on render. Shape:
{
"title": "Optional page title override",
"description": "Optional description shown above the table",
"columns": [{field, title, width?, format?, filter?, sort?, enum?}],
"rows": [{url, data, editable}],
"defaults": {sort?: [{field, dir}], filter?: {field: value}}
}
-->
<script id="table-context" type="application/json">{}</script>
<!--
Form mode context — server injects this for /<dir>/form.html and
/<dir>/<id>.yaml.html. Empty in table-mode renders.
-->
<script id="form-context" type="application/json">{}</script>
<script>
/*! js-yaml 4.1.0 https://github.com/nodeca/js-yaml @license MIT */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).jsyaml={})}(this,(function(e){"use strict";function t(e){return null==e}var n={isNothing:t,isObject:function(e){return"object"==typeof e&&null!==e},toArray:function(e){return Array.isArray(e)?e:t(e)?[]:[e]},repeat:function(e,t){var n,i="";for(n=0;n<t;n+=1)i+=e;return i},isNegativeZero:function(e){return 0===e&&Number.NEGATIVE_INFINITY===1/e},extend:function(e,t){var n,i,r,o;if(t)for(n=0,i=(o=Object.keys(t)).length;n<i;n+=1)e[r=o[n]]=t[r];return e}};function i(e,t){var n="",i=e.reason||"(unknown reason)";return e.mark?(e.mark.name&&(n+='in "'+e.mark.name+'" '),n+="("+(e.mark.line+1)+":"+(e.mark.column+1)+")",!t&&e.mark.snippet&&(n+="\n\n"+e.mark.snippet),i+" "+n):i}function r(e,t){Error.call(this),this.name="YAMLException",this.reason=e,this.mark=t,this.message=i(this,!1),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=(new Error).stack||""}r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r.prototype.toString=function(e){return this.name+": "+i(this,e)};var o=r;function a(e,t,n,i,r){var o="",a="",l=Math.floor(r/2)-1;return i-t>l&&(t=i-l+(o=" ... ").length),n-i>l&&(n=i+l-(a=" ...").length),{str:o+e.slice(t,n).replace(/\t/g,"→")+a,pos:i-t+o.length}}function l(e,t){return n.repeat(" ",t-e.length)+e}var c=function(e,t){if(t=Object.create(t||null),!e.buffer)return null;t.maxLength||(t.maxLength=79),"number"!=typeof t.indent&&(t.indent=1),"number"!=typeof t.linesBefore&&(t.linesBefore=3),"number"!=typeof t.linesAfter&&(t.linesAfter=2);for(var i,r=/\r?\n|\r|\0/g,o=[0],c=[],s=-1;i=r.exec(e.buffer);)c.push(i.index),o.push(i.index+i[0].length),e.position<=i.index&&s<0&&(s=o.length-2);s<0&&(s=o.length-1);var u,p,f="",d=Math.min(e.line+t.linesAfter,c.length).toString().length,h=t.maxLength-(t.indent+d+3);for(u=1;u<=t.linesBefore&&!(s-u<0);u++)p=a(e.buffer,o[s-u],c[s-u],e.position-(o[s]-o[s-u]),h),f=n.repeat(" ",t.indent)+l((e.line-u+1).toString(),d)+" | "+p.str+"\n"+f;for(p=a(e.buffer,o[s],c[s],e.position,h),f+=n.repeat(" ",t.indent)+l((e.line+1).toString(),d)+" | "+p.str+"\n",f+=n.repeat("-",t.indent+d+3+p.pos)+"^\n",u=1;u<=t.linesAfter&&!(s+u>=c.length);u++)p=a(e.buffer,o[s+u],c[s+u],e.position-(o[s]-o[s+u]),h),f+=n.repeat(" ",t.indent)+l((e.line+u+1).toString(),d)+" | "+p.str+"\n";return f.replace(/\n$/,"")},s=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],u=["scalar","sequence","mapping"];var p=function(e,t){if(t=t||{},Object.keys(t).forEach((function(t){if(-1===s.indexOf(t))throw new o('Unknown option "'+t+'" is met in definition of "'+e+'" YAML type.')})),this.options=t,this.tag=e,this.kind=t.kind||null,this.resolve=t.resolve||function(){return!0},this.construct=t.construct||function(e){return e},this.instanceOf=t.instanceOf||null,this.predicate=t.predicate||null,this.represent=t.represent||null,this.representName=t.representName||null,this.defaultStyle=t.defaultStyle||null,this.multi=t.multi||!1,this.styleAliases=function(e){var t={};return null!==e&&Object.keys(e).forEach((function(n){e[n].forEach((function(e){t[String(e)]=n}))})),t}(t.styleAliases||null),-1===u.indexOf(this.kind))throw new o('Unknown kind "'+this.kind+'" is specified for "'+e+'" YAML type.')};function f(e,t){var n=[];return e[t].forEach((function(e){var t=n.length;n.forEach((function(n,i){n.tag===e.tag&&n.kind===e.kind&&n.multi===e.multi&&(t=i)})),n[t]=e})),n}function d(e){return this.extend(e)}d.prototype.extend=function(e){var t=[],n=[];if(e instanceof p)n.push(e);else if(Array.isArray(e))n=n.concat(e);else{if(!e||!Array.isArray(e.implicit)&&!Array.isArray(e.explicit))throw new o("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");e.implicit&&(t=t.concat(e.implicit)),e.explicit&&(n=n.concat(e.explicit))}t.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.");if(e.loadKind&&"scalar"!==e.loadKind)throw new o("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");if(e.multi)throw new o("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.")})),n.forEach((function(e){if(!(e instanceof p))throw new o("Specified list of YAML types (or a single Type object) contains a non-Type object.")}));var i=Object.create(d.prototype);return i.implicit=(this.implicit||[]).concat(t),i.explicit=(this.explicit||[]).concat(n),i.compiledImplicit=f(i,"implicit"),i.compiledExplicit=f(i,"explicit"),i.compiledTypeMap=function(){var e,t,n={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function i(e){e.multi?(n.multi[e.kind].push(e),n.multi.fallback.push(e)):n[e.kind][e.tag]=n.fallback[e.tag]=e}for(e=0,t=arguments.length;e<t;e+=1)arguments[e].forEach(i);return n}(i.compiledImplicit,i.compiledExplicit),i};var h=d,g=new p("tag:yaml.org,2002:str",{kind:"scalar",construct:function(e){return null!==e?e:""}}),m=new p("tag:yaml.org,2002:seq",{kind:"sequence",construct:function(e){return null!==e?e:[]}}),y=new p("tag:yaml.org,2002:map",{kind:"mapping",construct:function(e){return null!==e?e:{}}}),b=new h({explicit:[g,m,y]});var A=new p("tag:yaml.org,2002:null",{kind:"scalar",resolve:function(e){if(null===e)return!0;var t=e.length;return 1===t&&"~"===e||4===t&&("null"===e||"Null"===e||"NULL"===e)},construct:function(){return null},predicate:function(e){return null===e},represent:{canonical:function(){return"~"},lowercase:function(){return"null"},uppercase:function(){return"NULL"},camelcase:function(){return"Null"},empty:function(){return""}},defaultStyle:"lowercase"});var v=new p("tag:yaml.org,2002:bool",{kind:"scalar",resolve:function(e){if(null===e)return!1;var t=e.length;return 4===t&&("true"===e||"True"===e||"TRUE"===e)||5===t&&("false"===e||"False"===e||"FALSE"===e)},construct:function(e){return"true"===e||"True"===e||"TRUE"===e},predicate:function(e){return"[object Boolean]"===Object.prototype.toString.call(e)},represent:{lowercase:function(e){return e?"true":"false"},uppercase:function(e){return e?"TRUE":"FALSE"},camelcase:function(e){return e?"True":"False"}},defaultStyle:"lowercase"});function w(e){return 48<=e&&e<=55}function k(e){return 48<=e&&e<=57}var C=new p("tag:yaml.org,2002:int",{kind:"scalar",resolve:function(e){if(null===e)return!1;var t,n,i=e.length,r=0,o=!1;if(!i)return!1;if("-"!==(t=e[r])&&"+"!==t||(t=e[++r]),"0"===t){if(r+1===i)return!0;if("b"===(t=e[++r])){for(r++;r<i;r++)if("_"!==(t=e[r])){if("0"!==t&&"1"!==t)return!1;o=!0}return o&&"_"!==t}if("x"===t){for(r++;r<i;r++)if("_"!==(t=e[r])){if(!(48<=(n=e.charCodeAt(r))&&n<=57||65<=n&&n<=70||97<=n&&n<=102))return!1;o=!0}return o&&"_"!==t}if("o"===t){for(r++;r<i;r++)if("_"!==(t=e[r])){if(!w(e.charCodeAt(r)))return!1;o=!0}return o&&"_"!==t}}if("_"===t)return!1;for(;r<i;r++)if("_"!==(t=e[r])){if(!k(e.charCodeAt(r)))return!1;o=!0}return!(!o||"_"===t)},construct:function(e){var t,n=e,i=1;if(-1!==n.indexOf("_")&&(n=n.replace(/_/g,"")),"-"!==(t=n[0])&&"+"!==t||("-"===t&&(i=-1),t=(n=n.slice(1))[0]),"0"===n)return 0;if("0"===t){if("b"===n[1])return i*parseInt(n.slice(2),2);if("x"===n[1])return i*parseInt(n.slice(2),16);if("o"===n[1])return i*parseInt(n.slice(2),8)}return i*parseInt(n,10)},predicate:function(e){return"[object Number]"===Object.prototype.toString.call(e)&&e%1==0&&!n.isNegativeZero(e)},represent:{binary:function(e){return e>=0?"0b"+e.toString(2):"-0b"+e.toString(2).slice(1)},octal:function(e){return e>=0?"0o"+e.toString(8):"-0o"+e.toString(8).slice(1)},decimal:function(e){return e.toString(10)},hexadecimal:function(e){return e>=0?"0x"+e.toString(16).toUpperCase():"-0x"+e.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}}),x=new RegExp("^(?:[-+]?(?:[0-9][0-9_]*)(?:\\.[0-9_]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9_]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");var I=/^[-+]?[0-9]+e/;var S=new p("tag:yaml.org,2002:float",{kind:"scalar",resolve:function(e){return null!==e&&!(!x.test(e)||"_"===e[e.length-1])},construct:function(e){var t,n;return n="-"===(t=e.replace(/_/g,"").toLowerCase())[0]?-1:1,"+-".indexOf(t[0])>=0&&(t=t.slice(1)),".inf"===t?1===n?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:".nan"===t?NaN:n*parseFloat(t,10)},predicate:function(e){return"[object Number]"===Object.prototype.toString.call(e)&&(e%1!=0||n.isNegativeZero(e))},represent:function(e,t){var i;if(isNaN(e))switch(t){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===e)switch(t){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===e)switch(t){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(n.isNegativeZero(e))return"-0.0";return i=e.toString(10),I.test(i)?i.replace("e",".e"):i},defaultStyle:"lowercase"}),O=b.extend({implicit:[A,v,C,S]}),j=O,T=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),N=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");var F=new p("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:function(e){return null!==e&&(null!==T.exec(e)||null!==N.exec(e))},construct:function(e){var t,n,i,r,o,a,l,c,s=0,u=null;if(null===(t=T.exec(e))&&(t=N.exec(e)),null===t)throw new Error("Date resolve error");if(n=+t[1],i=+t[2]-1,r=+t[3],!t[4])return new Date(Date.UTC(n,i,r));if(o=+t[4],a=+t[5],l=+t[6],t[7]){for(s=t[7].slice(0,3);s.length<3;)s+="0";s=+s}return t[9]&&(u=6e4*(60*+t[10]+ +(t[11]||0)),"-"===t[9]&&(u=-u)),c=new Date(Date.UTC(n,i,r,o,a,l,s)),u&&c.setTime(c.getTime()-u),c},instanceOf:Date,represent:function(e){return e.toISOString()}});var E=new p("tag:yaml.org,2002:merge",{kind:"scalar",resolve:function(e){return"<<"===e||null===e}}),M="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r";var L=new p("tag:yaml.org,2002:binary",{kind:"scalar",resolve:function(e){if(null===e)return!1;var t,n,i=0,r=e.length,o=M;for(n=0;n<r;n++)if(!((t=o.indexOf(e.charAt(n)))>64)){if(t<0)return!1;i+=6}return i%8==0},construct:function(e){var t,n,i=e.replace(/[\r\n=]/g,""),r=i.length,o=M,a=0,l=[];for(t=0;t<r;t++)t%4==0&&t&&(l.push(a>>16&255),l.push(a>>8&255),l.push(255&a)),a=a<<6|o.indexOf(i.charAt(t));return 0===(n=r%4*6)?(l.push(a>>16&255),l.push(a>>8&255),l.push(255&a)):18===n?(l.push(a>>10&255),l.push(a>>2&255)):12===n&&l.push(a>>4&255),new Uint8Array(l)},predicate:function(e){return"[object Uint8Array]"===Object.prototype.toString.call(e)},represent:function(e){var t,n,i="",r=0,o=e.length,a=M;for(t=0;t<o;t++)t%3==0&&t&&(i+=a[r>>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]),r=(r<<8)+e[t];return 0===(n=o%3)?(i+=a[r>>18&63],i+=a[r>>12&63],i+=a[r>>6&63],i+=a[63&r]):2===n?(i+=a[r>>10&63],i+=a[r>>4&63],i+=a[r<<2&63],i+=a[64]):1===n&&(i+=a[r>>2&63],i+=a[r<<4&63],i+=a[64],i+=a[64]),i}}),_=Object.prototype.hasOwnProperty,D=Object.prototype.toString;var U=new p("tag:yaml.org,2002:omap",{kind:"sequence",resolve:function(e){if(null===e)return!0;var t,n,i,r,o,a=[],l=e;for(t=0,n=l.length;t<n;t+=1){if(i=l[t],o=!1,"[object Object]"!==D.call(i))return!1;for(r in i)if(_.call(i,r)){if(o)return!1;o=!0}if(!o)return!1;if(-1!==a.indexOf(r))return!1;a.push(r)}return!0},construct:function(e){return null!==e?e:[]}}),q=Object.prototype.toString;var Y=new p("tag:yaml.org,2002:pairs",{kind:"sequence",resolve:function(e){if(null===e)return!0;var t,n,i,r,o,a=e;for(o=new Array(a.length),t=0,n=a.length;t<n;t+=1){if(i=a[t],"[object Object]"!==q.call(i))return!1;if(1!==(r=Object.keys(i)).length)return!1;o[t]=[r[0],i[r[0]]]}return!0},construct:function(e){if(null===e)return[];var t,n,i,r,o,a=e;for(o=new Array(a.length),t=0,n=a.length;t<n;t+=1)i=a[t],r=Object.keys(i),o[t]=[r[0],i[r[0]]];return o}}),R=Object.prototype.hasOwnProperty;var B=new p("tag:yaml.org,2002:set",{kind:"mapping",resolve:function(e){if(null===e)return!0;var t,n=e;for(t in n)if(R.call(n,t)&&null!==n[t])return!1;return!0},construct:function(e){return null!==e?e:{}}}),K=j.extend({implicit:[F,E],explicit:[L,U,Y,B]}),P=Object.prototype.hasOwnProperty,W=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,H=/[\x85\u2028\u2029]/,$=/[,\[\]\{\}]/,G=/^(?:!|!!|![a-z\-]+!)$/i,V=/^(?:!|[^,\[\]\{\}])(?:%[0-9a-f]{2}|[0-9a-z\-#;\/\?:@&=\+\$,_\.!~\*'\(\)\[\]])*$/i;function Z(e){return Object.prototype.toString.call(e)}function J(e){return 10===e||13===e}function Q(e){return 9===e||32===e}function z(e){return 9===e||32===e||10===e||13===e}function X(e){return 44===e||91===e||93===e||123===e||125===e}function ee(e){var t;return 48<=e&&e<=57?e-48:97<=(t=32|e)&&t<=102?t-97+10:-1}function te(e){return 48===e?"\0":97===e?"":98===e?"\b":116===e||9===e?"\t":110===e?"\n":118===e?"\v":102===e?"\f":114===e?"\r":101===e?"":32===e?" ":34===e?'"':47===e?"/":92===e?"\\":78===e?"…":95===e?" ":76===e?"\u2028":80===e?"\u2029":""}function ne(e){return e<=65535?String.fromCharCode(e):String.fromCharCode(55296+(e-65536>>10),56320+(e-65536&1023))}for(var ie=new Array(256),re=new Array(256),oe=0;oe<256;oe++)ie[oe]=te(oe)?1:0,re[oe]=te(oe);function ae(e,t){this.input=e,this.filename=t.filename||null,this.schema=t.schema||K,this.onWarning=t.onWarning||null,this.legacy=t.legacy||!1,this.json=t.json||!1,this.listener=t.listener||null,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=e.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.firstTabInLine=-1,this.documents=[]}function le(e,t){var n={name:e.filename,buffer:e.input.slice(0,-1),position:e.position,line:e.line,column:e.position-e.lineStart};return n.snippet=c(n),new o(t,n)}function ce(e,t){throw le(e,t)}function se(e,t){e.onWarning&&e.onWarning.call(null,le(e,t))}var ue={YAML:function(e,t,n){var i,r,o;null!==e.version&&ce(e,"duplication of %YAML directive"),1!==n.length&&ce(e,"YAML directive accepts exactly one argument"),null===(i=/^([0-9]+)\.([0-9]+)$/.exec(n[0]))&&ce(e,"ill-formed argument of the YAML directive"),r=parseInt(i[1],10),o=parseInt(i[2],10),1!==r&&ce(e,"unacceptable YAML version of the document"),e.version=n[0],e.checkLineBreaks=o<2,1!==o&&2!==o&&se(e,"unsupported YAML version of the document")},TAG:function(e,t,n){var i,r;2!==n.length&&ce(e,"TAG directive accepts exactly two arguments"),i=n[0],r=n[1],G.test(i)||ce(e,"ill-formed tag handle (first argument) of the TAG directive"),P.call(e.tagMap,i)&&ce(e,'there is a previously declared suffix for "'+i+'" tag handle'),V.test(r)||ce(e,"ill-formed tag prefix (second argument) of the TAG directive");try{r=decodeURIComponent(r)}catch(t){ce(e,"tag prefix is malformed: "+r)}e.tagMap[i]=r}};function pe(e,t,n,i){var r,o,a,l;if(t<n){if(l=e.input.slice(t,n),i)for(r=0,o=l.length;r<o;r+=1)9===(a=l.charCodeAt(r))||32<=a&&a<=1114111||ce(e,"expected valid JSON character");else W.test(l)&&ce(e,"the stream contains non-printable characters");e.result+=l}}function fe(e,t,i,r){var o,a,l,c;for(n.isObject(i)||ce(e,"cannot merge mappings; the provided source object is unacceptable"),l=0,c=(o=Object.keys(i)).length;l<c;l+=1)a=o[l],P.call(t,a)||(t[a]=i[a],r[a]=!0)}function de(e,t,n,i,r,o,a,l,c){var s,u;if(Array.isArray(r))for(s=0,u=(r=Array.prototype.slice.call(r)).length;s<u;s+=1)Array.isArray(r[s])&&ce(e,"nested arrays are not supported inside keys"),"object"==typeof r&&"[object Object]"===Z(r[s])&&(r[s]="[object Object]");if("object"==typeof r&&"[object Object]"===Z(r)&&(r="[object Object]"),r=String(r),null===t&&(t={}),"tag:yaml.org,2002:merge"===i)if(Array.isArray(o))for(s=0,u=o.length;s<u;s+=1)fe(e,t,o[s],n);else fe(e,t,o,n);else e.json||P.call(n,r)||!P.call(t,r)||(e.line=a||e.line,e.lineStart=l||e.lineStart,e.position=c||e.position,ce(e,"duplicated mapping key")),"__proto__"===r?Object.defineProperty(t,r,{configurable:!0,enumerable:!0,writable:!0,value:o}):t[r]=o,delete n[r];return t}function he(e){var t;10===(t=e.input.charCodeAt(e.position))?e.position++:13===t?(e.position++,10===e.input.charCodeAt(e.position)&&e.position++):ce(e,"a line break is expected"),e.line+=1,e.lineStart=e.position,e.firstTabInLine=-1}function ge(e,t,n){for(var i=0,r=e.input.charCodeAt(e.position);0!==r;){for(;Q(r);)9===r&&-1===e.firstTabInLine&&(e.firstTabInLine=e.position),r=e.input.charCodeAt(++e.position);if(t&&35===r)do{r=e.input.charCodeAt(++e.position)}while(10!==r&&13!==r&&0!==r);if(!J(r))break;for(he(e),r=e.input.charCodeAt(e.position),i++,e.lineIndent=0;32===r;)e.lineIndent++,r=e.input.charCodeAt(++e.position)}return-1!==n&&0!==i&&e.lineIndent<n&&se(e,"deficient indentation"),i}function me(e){var t,n=e.position;return!(45!==(t=e.input.charCodeAt(n))&&46!==t||t!==e.input.charCodeAt(n+1)||t!==e.input.charCodeAt(n+2)||(n+=3,0!==(t=e.input.charCodeAt(n))&&!z(t)))}function ye(e,t){1===t?e.result+=" ":t>1&&(e.result+=n.repeat("\n",t-1))}function be(e,t){var n,i,r=e.tag,o=e.anchor,a=[],l=!1;if(-1!==e.firstTabInLine)return!1;for(null!==e.anchor&&(e.anchorMap[e.anchor]=a),i=e.input.charCodeAt(e.position);0!==i&&(-1!==e.firstTabInLine&&(e.position=e.firstTabInLine,ce(e,"tab characters must not be used in indentation")),45===i)&&z(e.input.charCodeAt(e.position+1));)if(l=!0,e.position++,ge(e,!0,-1)&&e.lineIndent<=t)a.push(null),i=e.input.charCodeAt(e.position);else if(n=e.line,we(e,t,3,!1,!0),a.push(e.result),ge(e,!0,-1),i=e.input.charCodeAt(e.position),(e.line===n||e.lineIndent>t)&&0!==i)ce(e,"bad indentation of a sequence entry");else if(e.lineIndent<t)break;return!!l&&(e.tag=r,e.anchor=o,e.kind="sequence",e.result=a,!0)}function Ae(e){var t,n,i,r,o=!1,a=!1;if(33!==(r=e.input.charCodeAt(e.position)))return!1;if(null!==e.tag&&ce(e,"duplication of a tag property"),60===(r=e.input.charCodeAt(++e.position))?(o=!0,r=e.input.charCodeAt(++e.position)):33===r?(a=!0,n="!!",r=e.input.charCodeAt(++e.position)):n="!",t=e.position,o){do{r=e.input.charCodeAt(++e.position)}while(0!==r&&62!==r);e.position<e.length?(i=e.input.slice(t,e.position),r=e.input.charCodeAt(++e.position)):ce(e,"unexpected end of the stream within a verbatim tag")}else{for(;0!==r&&!z(r);)33===r&&(a?ce(e,"tag suffix cannot contain exclamation marks"):(n=e.input.slice(t-1,e.position+1),G.test(n)||ce(e,"named tag handle cannot contain such characters"),a=!0,t=e.position+1)),r=e.input.charCodeAt(++e.position);i=e.input.slice(t,e.position),$.test(i)&&ce(e,"tag suffix cannot contain flow indicator characters")}i&&!V.test(i)&&ce(e,"tag name cannot contain such characters: "+i);try{i=decodeURIComponent(i)}catch(t){ce(e,"tag name is malformed: "+i)}return o?e.tag=i:P.call(e.tagMap,n)?e.tag=e.tagMap[n]+i:"!"===n?e.tag="!"+i:"!!"===n?e.tag="tag:yaml.org,2002:"+i:ce(e,'undeclared tag handle "'+n+'"'),!0}function ve(e){var t,n;if(38!==(n=e.input.charCodeAt(e.position)))return!1;for(null!==e.anchor&&ce(e,"duplication of an anchor property"),n=e.input.charCodeAt(++e.position),t=e.position;0!==n&&!z(n)&&!X(n);)n=e.input.charCodeAt(++e.position);return e.position===t&&ce(e,"name of an anchor node must contain at least one character"),e.anchor=e.input.slice(t,e.position),!0}function we(e,t,i,r,o){var a,l,c,s,u,p,f,d,h,g=1,m=!1,y=!1;if(null!==e.listener&&e.listener("open",e),e.tag=null,e.anchor=null,e.kind=null,e.result=null,a=l=c=4===i||3===i,r&&ge(e,!0,-1)&&(m=!0,e.lineIndent>t?g=1:e.lineIndent===t?g=0:e.lineIndent<t&&(g=-1)),1===g)for(;Ae(e)||ve(e);)ge(e,!0,-1)?(m=!0,c=a,e.lineIndent>t?g=1:e.lineIndent===t?g=0:e.lineIndent<t&&(g=-1)):c=!1;if(c&&(c=m||o),1!==g&&4!==i||(d=1===i||2===i?t:t+1,h=e.position-e.lineStart,1===g?c&&(be(e,h)||function(e,t,n){var i,r,o,a,l,c,s,u=e.tag,p=e.anchor,f={},d=Object.create(null),h=null,g=null,m=null,y=!1,b=!1;if(-1!==e.firstTabInLine)return!1;for(null!==e.anchor&&(e.anchorMap[e.anchor]=f),s=e.input.charCodeAt(e.position);0!==s;){if(y||-1===e.firstTabInLine||(e.position=e.firstTabInLine,ce(e,"tab characters must not be used in indentation")),i=e.input.charCodeAt(e.position+1),o=e.line,63!==s&&58!==s||!z(i)){if(a=e.line,l=e.lineStart,c=e.position,!we(e,n,2,!1,!0))break;if(e.line===o){for(s=e.input.charCodeAt(e.position);Q(s);)s=e.input.charCodeAt(++e.position);if(58===s)z(s=e.input.charCodeAt(++e.position))||ce(e,"a whitespace character is expected after the key-value separator within a block mapping"),y&&(de(e,f,d,h,g,null,a,l,c),h=g=m=null),b=!0,y=!1,r=!1,h=e.tag,g=e.result;else{if(!b)return e.tag=u,e.anchor=p,!0;ce(e,"can not read an implicit mapping pair; a colon is missed")}}else{if(!b)return e.tag=u,e.anchor=p,!0;ce(e,"can not read a block mapping entry; a multiline key may not be an implicit key")}}else 63===s?(y&&(de(e,f,d,h,g,null,a,l,c),h=g=m=null),b=!0,y=!0,r=!0):y?(y=!1,r=!0):ce(e,"incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line"),e.position+=1,s=i;if((e.line===o||e.lineIndent>t)&&(y&&(a=e.line,l=e.lineStart,c=e.position),we(e,t,4,!0,r)&&(y?g=e.result:m=e.result),y||(de(e,f,d,h,g,m,a,l,c),h=g=m=null),ge(e,!0,-1),s=e.input.charCodeAt(e.position)),(e.line===o||e.lineIndent>t)&&0!==s)ce(e,"bad indentation of a mapping entry");else if(e.lineIndent<t)break}return y&&de(e,f,d,h,g,null,a,l,c),b&&(e.tag=u,e.anchor=p,e.kind="mapping",e.result=f),b}(e,h,d))||function(e,t){var n,i,r,o,a,l,c,s,u,p,f,d,h=!0,g=e.tag,m=e.anchor,y=Object.create(null);if(91===(d=e.input.charCodeAt(e.position)))a=93,s=!1,o=[];else{if(123!==d)return!1;a=125,s=!0,o={}}for(null!==e.anchor&&(e.anchorMap[e.anchor]=o),d=e.input.charCodeAt(++e.position);0!==d;){if(ge(e,!0,t),(d=e.input.charCodeAt(e.position))===a)return e.position++,e.tag=g,e.anchor=m,e.kind=s?"mapping":"sequence",e.result=o,!0;h?44===d&&ce(e,"expected the node content, but found ','"):ce(e,"missed comma between flow collection entries"),f=null,l=c=!1,63===d&&z(e.input.charCodeAt(e.position+1))&&(l=c=!0,e.position++,ge(e,!0,t)),n=e.line,i=e.lineStart,r=e.position,we(e,t,1,!1,!0),p=e.tag,u=e.result,ge(e,!0,t),d=e.input.charCodeAt(e.position),!c&&e.line!==n||58!==d||(l=!0,d=e.input.charCodeAt(++e.position),ge(e,!0,t),we(e,t,1,!1,!0),f=e.result),s?de(e,o,y,p,u,f,n,i,r):l?o.push(de(e,null,y,p,u,f,n,i,r)):o.push(u),ge(e,!0,t),44===(d=e.input.charCodeAt(e.position))?(h=!0,d=e.input.charCodeAt(++e.position)):h=!1}ce(e,"unexpected end of the stream within a flow collection")}(e,d)?y=!0:(l&&function(e,t){var i,r,o,a,l,c=1,s=!1,u=!1,p=t,f=0,d=!1;if(124===(a=e.input.charCodeAt(e.position)))r=!1;else{if(62!==a)return!1;r=!0}for(e.kind="scalar",e.result="";0!==a;)if(43===(a=e.input.charCodeAt(++e.position))||45===a)1===c?c=43===a?3:2:ce(e,"repeat of a chomping mode identifier");else{if(!((o=48<=(l=a)&&l<=57?l-48:-1)>=0))break;0===o?ce(e,"bad explicit indentation width of a block scalar; it cannot be less than one"):u?ce(e,"repeat of an indentation width identifier"):(p=t+o-1,u=!0)}if(Q(a)){do{a=e.input.charCodeAt(++e.position)}while(Q(a));if(35===a)do{a=e.input.charCodeAt(++e.position)}while(!J(a)&&0!==a)}for(;0!==a;){for(he(e),e.lineIndent=0,a=e.input.charCodeAt(e.position);(!u||e.lineIndent<p)&&32===a;)e.lineIndent++,a=e.input.charCodeAt(++e.position);if(!u&&e.lineIndent>p&&(p=e.lineIndent),J(a))f++;else{if(e.lineIndent<p){3===c?e.result+=n.repeat("\n",s?1+f:f):1===c&&s&&(e.result+="\n");break}for(r?Q(a)?(d=!0,e.result+=n.repeat("\n",s?1+f:f)):d?(d=!1,e.result+=n.repeat("\n",f+1)):0===f?s&&(e.result+=" "):e.result+=n.repeat("\n",f):e.result+=n.repeat("\n",s?1+f:f),s=!0,u=!0,f=0,i=e.position;!J(a)&&0!==a;)a=e.input.charCodeAt(++e.position);pe(e,i,e.position,!1)}}return!0}(e,d)||function(e,t){var n,i,r;if(39!==(n=e.input.charCodeAt(e.position)))return!1;for(e.kind="scalar",e.result="",e.position++,i=r=e.position;0!==(n=e.input.charCodeAt(e.position));)if(39===n){if(pe(e,i,e.position,!0),39!==(n=e.input.charCodeAt(++e.position)))return!0;i=e.position,e.position++,r=e.position}else J(n)?(pe(e,i,r,!0),ye(e,ge(e,!1,t)),i=r=e.position):e.position===e.lineStart&&me(e)?ce(e,"unexpected end of the document within a single quoted scalar"):(e.position++,r=e.position);ce(e,"unexpected end of the stream within a single quoted scalar")}(e,d)||function(e,t){var n,i,r,o,a,l,c;if(34!==(l=e.input.charCodeAt(e.position)))return!1;for(e.kind="scalar",e.result="",e.position++,n=i=e.position;0!==(l=e.input.charCodeAt(e.position));){if(34===l)return pe(e,n,e.position,!0),e.position++,!0;if(92===l){if(pe(e,n,e.position,!0),J(l=e.input.charCodeAt(++e.position)))ge(e,!1,t);else if(l<256&&ie[l])e.result+=re[l],e.position++;else if((a=120===(c=l)?2:117===c?4:85===c?8:0)>0){for(r=a,o=0;r>0;r--)(a=ee(l=e.input.charCodeAt(++e.position)))>=0?o=(o<<4)+a:ce(e,"expected hexadecimal character");e.result+=ne(o),e.position++}else ce(e,"unknown escape sequence");n=i=e.position}else J(l)?(pe(e,n,i,!0),ye(e,ge(e,!1,t)),n=i=e.position):e.position===e.lineStart&&me(e)?ce(e,"unexpected end of the document within a double quoted scalar"):(e.position++,i=e.position)}ce(e,"unexpected end of the stream within a double quoted scalar")}(e,d)?y=!0:!function(e){var t,n,i;if(42!==(i=e.input.charCodeAt(e.position)))return!1;for(i=e.input.charCodeAt(++e.position),t=e.position;0!==i&&!z(i)&&!X(i);)i=e.input.charCodeAt(++e.position);return e.position===t&&ce(e,"name of an alias node must contain at least one character"),n=e.input.slice(t,e.position),P.call(e.anchorMap,n)||ce(e,'unidentified alias "'+n+'"'),e.result=e.anchorMap[n],ge(e,!0,-1),!0}(e)?function(e,t,n){var i,r,o,a,l,c,s,u,p=e.kind,f=e.result;if(z(u=e.input.charCodeAt(e.position))||X(u)||35===u||38===u||42===u||33===u||124===u||62===u||39===u||34===u||37===u||64===u||96===u)return!1;if((63===u||45===u)&&(z(i=e.input.charCodeAt(e.position+1))||n&&X(i)))return!1;for(e.kind="scalar",e.result="",r=o=e.position,a=!1;0!==u;){if(58===u){if(z(i=e.input.charCodeAt(e.position+1))||n&&X(i))break}else if(35===u){if(z(e.input.charCodeAt(e.position-1)))break}else{if(e.position===e.lineStart&&me(e)||n&&X(u))break;if(J(u)){if(l=e.line,c=e.lineStart,s=e.lineIndent,ge(e,!1,-1),e.lineIndent>=t){a=!0,u=e.input.charCodeAt(e.position);continue}e.position=o,e.line=l,e.lineStart=c,e.lineIndent=s;break}}a&&(pe(e,r,o,!1),ye(e,e.line-l),r=o=e.position,a=!1),Q(u)||(o=e.position+1),u=e.input.charCodeAt(++e.position)}return pe(e,r,o,!1),!!e.result||(e.kind=p,e.result=f,!1)}(e,d,1===i)&&(y=!0,null===e.tag&&(e.tag="?")):(y=!0,null===e.tag&&null===e.anchor||ce(e,"alias node should not have any properties")),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):0===g&&(y=c&&be(e,h))),null===e.tag)null!==e.anchor&&(e.anchorMap[e.anchor]=e.result);else if("?"===e.tag){for(null!==e.result&&"scalar"!==e.kind&&ce(e,'unacceptable node kind for !<?> tag; it should be "scalar", not "'+e.kind+'"'),s=0,u=e.implicitTypes.length;s<u;s+=1)if((f=e.implicitTypes[s]).resolve(e.result)){e.result=f.construct(e.result),e.tag=f.tag,null!==e.anchor&&(e.anchorMap[e.anchor]=e.result);break}}else if("!"!==e.tag){if(P.call(e.typeMap[e.kind||"fallback"],e.tag))f=e.typeMap[e.kind||"fallback"][e.tag];else for(f=null,s=0,u=(p=e.typeMap.multi[e.kind||"fallback"]).length;s<u;s+=1)if(e.tag.slice(0,p[s].tag.length)===p[s].tag){f=p[s];break}f||ce(e,"unknown tag !<"+e.tag+">"),null!==e.result&&f.kind!==e.kind&&ce(e,"unacceptable node kind for !<"+e.tag+'> tag; it should be "'+f.kind+'", not "'+e.kind+'"'),f.resolve(e.result,e.tag)?(e.result=f.construct(e.result,e.tag),null!==e.anchor&&(e.anchorMap[e.anchor]=e.result)):ce(e,"cannot resolve a node with !<"+e.tag+"> explicit tag")}return null!==e.listener&&e.listener("close",e),null!==e.tag||null!==e.anchor||y}function ke(e){var t,n,i,r,o=e.position,a=!1;for(e.version=null,e.checkLineBreaks=e.legacy,e.tagMap=Object.create(null),e.anchorMap=Object.create(null);0!==(r=e.input.charCodeAt(e.position))&&(ge(e,!0,-1),r=e.input.charCodeAt(e.position),!(e.lineIndent>0||37!==r));){for(a=!0,r=e.input.charCodeAt(++e.position),t=e.position;0!==r&&!z(r);)r=e.input.charCodeAt(++e.position);for(i=[],(n=e.input.slice(t,e.position)).length<1&&ce(e,"directive name must not be less than one character in length");0!==r;){for(;Q(r);)r=e.input.charCodeAt(++e.position);if(35===r){do{r=e.input.charCodeAt(++e.position)}while(0!==r&&!J(r));break}if(J(r))break;for(t=e.position;0!==r&&!z(r);)r=e.input.charCodeAt(++e.position);i.push(e.input.slice(t,e.position))}0!==r&&he(e),P.call(ue,n)?ue[n](e,n,i):se(e,'unknown document directive "'+n+'"')}ge(e,!0,-1),0===e.lineIndent&&45===e.input.charCodeAt(e.position)&&45===e.input.charCodeAt(e.position+1)&&45===e.input.charCodeAt(e.position+2)?(e.position+=3,ge(e,!0,-1)):a&&ce(e,"directives end mark is expected"),we(e,e.lineIndent-1,4,!1,!0),ge(e,!0,-1),e.checkLineBreaks&&H.test(e.input.slice(o,e.position))&&se(e,"non-ASCII line breaks are interpreted as content"),e.documents.push(e.result),e.position===e.lineStart&&me(e)?46===e.input.charCodeAt(e.position)&&(e.position+=3,ge(e,!0,-1)):e.position<e.length-1&&ce(e,"end of the stream or a document separator is expected")}function Ce(e,t){t=t||{},0!==(e=String(e)).length&&(10!==e.charCodeAt(e.length-1)&&13!==e.charCodeAt(e.length-1)&&(e+="\n"),65279===e.charCodeAt(0)&&(e=e.slice(1)));var n=new ae(e,t),i=e.indexOf("\0");for(-1!==i&&(n.position=i,ce(n,"null byte is not allowed in input")),n.input+="\0";32===n.input.charCodeAt(n.position);)n.lineIndent+=1,n.position+=1;for(;n.position<n.length-1;)ke(n);return n.documents}var xe={loadAll:function(e,t,n){null!==t&&"object"==typeof t&&void 0===n&&(n=t,t=null);var i=Ce(e,n);if("function"!=typeof t)return i;for(var r=0,o=i.length;r<o;r+=1)t(i[r])},load:function(e,t){var n=Ce(e,t);if(0!==n.length){if(1===n.length)return n[0];throw new o("expected a single document in the stream, but found more")}}},Ie=Object.prototype.toString,Se=Object.prototype.hasOwnProperty,Oe=65279,je={0:"\\0",7:"\\a",8:"\\b",9:"\\t",10:"\\n",11:"\\v",12:"\\f",13:"\\r",27:"\\e",34:'\\"',92:"\\\\",133:"\\N",160:"\\_",8232:"\\L",8233:"\\P"},Te=["y","Y","yes","Yes","YES","on","On","ON","n","N","no","No","NO","off","Off","OFF"],Ne=/^[-+]?[0-9_]+(?::[0-9_]+)+(?:\.[0-9_]*)?$/;function Fe(e){var t,i,r;if(t=e.toString(16).toUpperCase(),e<=255)i="x",r=2;else if(e<=65535)i="u",r=4;else{if(!(e<=4294967295))throw new o("code point within a string may not be greater than 0xFFFFFFFF");i="U",r=8}return"\\"+i+n.repeat("0",r-t.length)+t}function Ee(e){this.schema=e.schema||K,this.indent=Math.max(1,e.indent||2),this.noArrayIndent=e.noArrayIndent||!1,this.skipInvalid=e.skipInvalid||!1,this.flowLevel=n.isNothing(e.flowLevel)?-1:e.flowLevel,this.styleMap=function(e,t){var n,i,r,o,a,l,c;if(null===t)return{};for(n={},r=0,o=(i=Object.keys(t)).length;r<o;r+=1)a=i[r],l=String(t[a]),"!!"===a.slice(0,2)&&(a="tag:yaml.org,2002:"+a.slice(2)),(c=e.compiledTypeMap.fallback[a])&&Se.call(c.styleAliases,l)&&(l=c.styleAliases[l]),n[a]=l;return n}(this.schema,e.styles||null),this.sortKeys=e.sortKeys||!1,this.lineWidth=e.lineWidth||80,this.noRefs=e.noRefs||!1,this.noCompatMode=e.noCompatMode||!1,this.condenseFlow=e.condenseFlow||!1,this.quotingType='"'===e.quotingType?2:1,this.forceQuotes=e.forceQuotes||!1,this.replacer="function"==typeof e.replacer?e.replacer:null,this.implicitTypes=this.schema.compiledImplicit,this.explicitTypes=this.schema.compiledExplicit,this.tag=null,this.result="",this.duplicates=[],this.usedDuplicates=null}function Me(e,t){for(var i,r=n.repeat(" ",t),o=0,a=-1,l="",c=e.length;o<c;)-1===(a=e.indexOf("\n",o))?(i=e.slice(o),o=c):(i=e.slice(o,a+1),o=a+1),i.length&&"\n"!==i&&(l+=r),l+=i;return l}function Le(e,t){return"\n"+n.repeat(" ",e.indent*t)}function _e(e){return 32===e||9===e}function De(e){return 32<=e&&e<=126||161<=e&&e<=55295&&8232!==e&&8233!==e||57344<=e&&e<=65533&&e!==Oe||65536<=e&&e<=1114111}function Ue(e){return De(e)&&e!==Oe&&13!==e&&10!==e}function qe(e,t,n){var i=Ue(e),r=i&&!_e(e);return(n?i:i&&44!==e&&91!==e&&93!==e&&123!==e&&125!==e)&&35!==e&&!(58===t&&!r)||Ue(t)&&!_e(t)&&35===e||58===t&&r}function Ye(e,t){var n,i=e.charCodeAt(t);return i>=55296&&i<=56319&&t+1<e.length&&(n=e.charCodeAt(t+1))>=56320&&n<=57343?1024*(i-55296)+n-56320+65536:i}function Re(e){return/^\n* /.test(e)}function Be(e,t,n,i,r,o,a,l){var c,s,u=0,p=null,f=!1,d=!1,h=-1!==i,g=-1,m=De(s=Ye(e,0))&&s!==Oe&&!_e(s)&&45!==s&&63!==s&&58!==s&&44!==s&&91!==s&&93!==s&&123!==s&&125!==s&&35!==s&&38!==s&&42!==s&&33!==s&&124!==s&&61!==s&&62!==s&&39!==s&&34!==s&&37!==s&&64!==s&&96!==s&&function(e){return!_e(e)&&58!==e}(Ye(e,e.length-1));if(t||a)for(c=0;c<e.length;u>=65536?c+=2:c++){if(!De(u=Ye(e,c)))return 5;m=m&&qe(u,p,l),p=u}else{for(c=0;c<e.length;u>=65536?c+=2:c++){if(10===(u=Ye(e,c)))f=!0,h&&(d=d||c-g-1>i&&" "!==e[g+1],g=c);else if(!De(u))return 5;m=m&&qe(u,p,l),p=u}d=d||h&&c-g-1>i&&" "!==e[g+1]}return f||d?n>9&&Re(e)?5:a?2===o?5:2:d?4:3:!m||a||r(e)?2===o?5:2:1}function Ke(e,t,n,i,r){e.dump=function(){if(0===t.length)return 2===e.quotingType?'""':"''";if(!e.noCompatMode&&(-1!==Te.indexOf(t)||Ne.test(t)))return 2===e.quotingType?'"'+t+'"':"'"+t+"'";var a=e.indent*Math.max(1,n),l=-1===e.lineWidth?-1:Math.max(Math.min(e.lineWidth,40),e.lineWidth-a),c=i||e.flowLevel>-1&&n>=e.flowLevel;switch(Be(t,c,e.indent,l,(function(t){return function(e,t){var n,i;for(n=0,i=e.implicitTypes.length;n<i;n+=1)if(e.implicitTypes[n].resolve(t))return!0;return!1}(e,t)}),e.quotingType,e.forceQuotes&&!i,r)){case 1:return t;case 2:return"'"+t.replace(/'/g,"''")+"'";case 3:return"|"+Pe(t,e.indent)+We(Me(t,a));case 4:return">"+Pe(t,e.indent)+We(Me(function(e,t){var n,i,r=/(\n+)([^\n]*)/g,o=(l=e.indexOf("\n"),l=-1!==l?l:e.length,r.lastIndex=l,He(e.slice(0,l),t)),a="\n"===e[0]||" "===e[0];var l;for(;i=r.exec(e);){var c=i[1],s=i[2];n=" "===s[0],o+=c+(a||n||""===s?"":"\n")+He(s,t),a=n}return o}(t,l),a));case 5:return'"'+function(e){for(var t,n="",i=0,r=0;r<e.length;i>=65536?r+=2:r++)i=Ye(e,r),!(t=je[i])&&De(i)?(n+=e[r],i>=65536&&(n+=e[r+1])):n+=t||Fe(i);return n}(t)+'"';default:throw new o("impossible error: invalid scalar style")}}()}function Pe(e,t){var n=Re(e)?String(t):"",i="\n"===e[e.length-1];return n+(i&&("\n"===e[e.length-2]||"\n"===e)?"+":i?"":"-")+"\n"}function We(e){return"\n"===e[e.length-1]?e.slice(0,-1):e}function He(e,t){if(""===e||" "===e[0])return e;for(var n,i,r=/ [^ ]/g,o=0,a=0,l=0,c="";n=r.exec(e);)(l=n.index)-o>t&&(i=a>o?a:l,c+="\n"+e.slice(o,i),o=i+1),a=l;return c+="\n",e.length-o>t&&a>o?c+=e.slice(o,a)+"\n"+e.slice(a+1):c+=e.slice(o),c.slice(1)}function $e(e,t,n,i){var r,o,a,l="",c=e.tag;for(r=0,o=n.length;r<o;r+=1)a=n[r],e.replacer&&(a=e.replacer.call(n,String(r),a)),(Ve(e,t+1,a,!0,!0,!1,!0)||void 0===a&&Ve(e,t+1,null,!0,!0,!1,!0))&&(i&&""===l||(l+=Le(e,t)),e.dump&&10===e.dump.charCodeAt(0)?l+="-":l+="- ",l+=e.dump);e.tag=c,e.dump=l||"[]"}function Ge(e,t,n){var i,r,a,l,c,s;for(a=0,l=(r=n?e.explicitTypes:e.implicitTypes).length;a<l;a+=1)if(((c=r[a]).instanceOf||c.predicate)&&(!c.instanceOf||"object"==typeof t&&t instanceof c.instanceOf)&&(!c.predicate||c.predicate(t))){if(n?c.multi&&c.representName?e.tag=c.representName(t):e.tag=c.tag:e.tag="?",c.represent){if(s=e.styleMap[c.tag]||c.defaultStyle,"[object Function]"===Ie.call(c.represent))i=c.represent(t,s);else{if(!Se.call(c.represent,s))throw new o("!<"+c.tag+'> tag resolver accepts not "'+s+'" style');i=c.represent[s](t,s)}e.dump=i}return!0}return!1}function Ve(e,t,n,i,r,a,l){e.tag=null,e.dump=n,Ge(e,n,!1)||Ge(e,n,!0);var c,s=Ie.call(e.dump),u=i;i&&(i=e.flowLevel<0||e.flowLevel>t);var p,f,d="[object Object]"===s||"[object Array]"===s;if(d&&(f=-1!==(p=e.duplicates.indexOf(n))),(null!==e.tag&&"?"!==e.tag||f||2!==e.indent&&t>0)&&(r=!1),f&&e.usedDuplicates[p])e.dump="*ref_"+p;else{if(d&&f&&!e.usedDuplicates[p]&&(e.usedDuplicates[p]=!0),"[object Object]"===s)i&&0!==Object.keys(e.dump).length?(!function(e,t,n,i){var r,a,l,c,s,u,p="",f=e.tag,d=Object.keys(n);if(!0===e.sortKeys)d.sort();else if("function"==typeof e.sortKeys)d.sort(e.sortKeys);else if(e.sortKeys)throw new o("sortKeys must be a boolean or a function");for(r=0,a=d.length;r<a;r+=1)u="",i&&""===p||(u+=Le(e,t)),c=n[l=d[r]],e.replacer&&(c=e.replacer.call(n,l,c)),Ve(e,t+1,l,!0,!0,!0)&&((s=null!==e.tag&&"?"!==e.tag||e.dump&&e.dump.length>1024)&&(e.dump&&10===e.dump.charCodeAt(0)?u+="?":u+="? "),u+=e.dump,s&&(u+=Le(e,t)),Ve(e,t+1,c,!0,s)&&(e.dump&&10===e.dump.charCodeAt(0)?u+=":":u+=": ",p+=u+=e.dump));e.tag=f,e.dump=p||"{}"}(e,t,e.dump,r),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var i,r,o,a,l,c="",s=e.tag,u=Object.keys(n);for(i=0,r=u.length;i<r;i+=1)l="",""!==c&&(l+=", "),e.condenseFlow&&(l+='"'),a=n[o=u[i]],e.replacer&&(a=e.replacer.call(n,o,a)),Ve(e,t,o,!1,!1)&&(e.dump.length>1024&&(l+="? "),l+=e.dump+(e.condenseFlow?'"':"")+":"+(e.condenseFlow?"":" "),Ve(e,t,a,!1,!1)&&(c+=l+=e.dump));e.tag=s,e.dump="{"+c+"}"}(e,t,e.dump),f&&(e.dump="&ref_"+p+" "+e.dump));else if("[object Array]"===s)i&&0!==e.dump.length?(e.noArrayIndent&&!l&&t>0?$e(e,t-1,e.dump,r):$e(e,t,e.dump,r),f&&(e.dump="&ref_"+p+e.dump)):(!function(e,t,n){var i,r,o,a="",l=e.tag;for(i=0,r=n.length;i<r;i+=1)o=n[i],e.replacer&&(o=e.replacer.call(n,String(i),o)),(Ve(e,t,o,!1,!1)||void 0===o&&Ve(e,t,null,!1,!1))&&(""!==a&&(a+=","+(e.condenseFlow?"":" ")),a+=e.dump);e.tag=l,e.dump="["+a+"]"}(e,t,e.dump),f&&(e.dump="&ref_"+p+" "+e.dump));else{if("[object String]"!==s){if("[object Undefined]"===s)return!1;if(e.skipInvalid)return!1;throw new o("unacceptable kind of an object to dump "+s)}"?"!==e.tag&&Ke(e,e.dump,t,a,u)}null!==e.tag&&"?"!==e.tag&&(c=encodeURI("!"===e.tag[0]?e.tag.slice(1):e.tag).replace(/!/g,"%21"),c="!"===e.tag[0]?"!"+c:"tag:yaml.org,2002:"===c.slice(0,18)?"!!"+c.slice(18):"!<"+c+">",e.dump=c+" "+e.dump)}return!0}function Ze(e,t){var n,i,r=[],o=[];for(Je(e,r,o),n=0,i=o.length;n<i;n+=1)t.duplicates.push(r[o[n]]);t.usedDuplicates=new Array(i)}function Je(e,t,n){var i,r,o;if(null!==e&&"object"==typeof e)if(-1!==(r=t.indexOf(e)))-1===n.indexOf(r)&&n.push(r);else if(t.push(e),Array.isArray(e))for(r=0,o=e.length;r<o;r+=1)Je(e[r],t,n);else for(r=0,o=(i=Object.keys(e)).length;r<o;r+=1)Je(e[i[r]],t,n)}function Qe(e,t){return function(){throw new Error("Function yaml."+e+" is removed in js-yaml 4. Use yaml."+t+" instead, which is now safe by default.")}}var ze=p,Xe=h,et=b,tt=O,nt=j,it=K,rt=xe.load,ot=xe.loadAll,at={dump:function(e,t){var n=new Ee(t=t||{});n.noRefs||Ze(e,n);var i=e;return n.replacer&&(i=n.replacer.call({"":i},"",i)),Ve(n,0,i,!0,!0)?n.dump+"\n":""}}.dump,lt=o,ct={binary:L,float:S,map:y,null:A,pairs:Y,set:B,timestamp:F,bool:v,int:C,merge:E,omap:U,seq:m,str:g},st=Qe("safeLoad","load"),ut=Qe("safeLoadAll","loadAll"),pt=Qe("safeDump","dump"),ft={Type:ze,Schema:Xe,FAILSAFE_SCHEMA:et,JSON_SCHEMA:tt,CORE_SCHEMA:nt,DEFAULT_SCHEMA:it,load:rt,loadAll:ot,dump:at,YAMLException:lt,types:ct,safeLoad:st,safeLoadAll:ut,safeDump:pt};e.CORE_SCHEMA=nt,e.DEFAULT_SCHEMA=it,e.FAILSAFE_SCHEMA=et,e.JSON_SCHEMA=tt,e.Schema=Xe,e.Type=ze,e.YAMLException=lt,e.default=ft,e.dump=at,e.load=rt,e.loadAll=ot,e.safeDump=pt,e.safeLoad=st,e.safeLoadAll=ut,e.types=ct,Object.defineProperty(e,"__esModule",{value:!0})}));
/**
* ZDDC — shared naming convention library
*
* Canonical implementation of all ZDDC filename, folder name, tracking number,
* revision, and status logic. Included in every tool's build via shared/zddc.js.
*
* Exposed as window.zddc (plain global) so it works with every tool's module
* pattern (archive globals, classifier IIFE, transmittal IIFE, mdedit globals).
*
* Public API
* ----------
* zddc.parseFilename(str) → ParsedFile | null
* zddc.parseFolder(str) → ParsedFolder | null
* zddc.parseRevision(str) → ParsedRevision
* zddc.formatFilename(parts) → string
* zddc.formatFolder(parts) → string
* zddc.compareRevisions(a, b) → number (-1 | 0 | 1)
* zddc.isValidStatus(str) → boolean
* zddc.STATUSES → string[]
*
* ParsedFile { trackingNumber, revision, status, title, extension }
* ParsedFolder { date, trackingNumber, status, title }
* ParsedRevision { base, modifier, modifierType, modifierNumber, isDraft, full }
*/
(function (root) {
'use strict';
// ── Valid status codes ───────────────────────────────────────────────────
/**
* Complete list of valid ZDDC document status codes.
* '---' denotes an unknown or not-yet-assigned status.
*/
var STATUSES = [
'---',
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
];
var STATUS_SET = {};
for (var _i = 0; _i < STATUSES.length; _i++) {
STATUS_SET[STATUSES[_i]] = true;
}
function isValidStatus(str) {
return !!STATUS_SET[str];
}
// ── Filename parsing ─────────────────────────────────────────────────────
/**
* Canonical file regex.
* Matches: TRACKING_REVISION (STATUS) - TITLE.EXT
*
* Tracking number: no underscores, no whitespace.
* Revision: no whitespace, no parentheses.
* Status: anything inside parentheses (validated separately).
* Title: everything up to the last dot.
* Extension: after the last dot (lowercased by parseFilename).
*/
var FILE_RE = /^([^_\s]+)_([^\s()_]+)\s*\(([^)]+)\)\s*-\s*(\S.*\S|\S)\.\s*([^\s.]+)$/;
/**
* Parse a ZDDC filename.
*
* @param {string} filename
* @returns {{ trackingNumber: string, revision: string, status: string,
* title: string, extension: string, valid: boolean } | null}
* null only if filename is falsy.
* `valid` is true when all fields matched the ZDDC pattern.
*/
function parseFilename(filename) {
if (!filename) { return null; }
var match = filename.match(FILE_RE);
if (!match) {
var lastDot = filename.lastIndexOf('.');
return {
trackingNumber: '',
revision: '',
status: '',
title: lastDot > 0 ? filename.substring(0, lastDot) : filename,
extension: lastDot > 0 ? filename.substring(lastDot + 1).toLowerCase() : '',
valid: false,
};
}
return {
trackingNumber: match[1].trim(),
revision: match[2].trim(),
status: match[3].trim(),
title: match[4].trim(),
extension: match[5].toLowerCase(),
valid: true,
};
}
// ── Folder name parsing ──────────────────────────────────────────────────
/**
* Transmittal folder regex.
* Matches: YYYY-MM-DD_TRACKING (STATUS) - TITLE
*/
var FOLDER_RE = /^(\d{4}-\d{2}-\d{2})_([^_\s(]+)\s*\(([^)]+)\)\s*-\s*(.+)$/;
/**
* Parse a ZDDC transmittal folder name.
*
* @param {string} foldername
* @returns {{ date: string, trackingNumber: string, status: string,
* title: string, valid: boolean } | null}
* null only if foldername is falsy.
*/
function parseFolder(foldername) {
if (!foldername) { return null; }
var match = foldername.match(FOLDER_RE);
if (!match) {
return {
date: '',
trackingNumber: '',
status: '',
title: foldername,
valid: false,
};
}
return {
date: match[1],
trackingNumber: match[2].trim(),
status: match[3].trim(),
title: match[4].trim(),
valid: true,
};
}
// ── Revision parsing ─────────────────────────────────────────────────────
/**
* Modifier sub-regex: +LETTER DIGITS e.g. +C1, +B2, +N1, +Q1
* The draft prefix (~) may appear inside the modifier: A+~C1
*/
var MODIFIER_RE = /^\+(~?)([A-Za-z])(\d+)$/;
/**
* Parse a ZDDC revision string.
*
* Revision grammar:
* revision = ['~'] base ['+' ['~'] modifier_letter modifier_number]
* base = letter(s) | digit(s) | date(YYYY-MM-DD)
* modifier = letter + digits e.g. C1, B2, N1, Q1
*
* @param {string} revision
* @returns {{
* base: string,
* modifier: string, full modifier string e.g. '+C1', '' if none
* modifierType: string, modifier letter e.g. 'C', '' if none
* modifierNumber: number, modifier number e.g. 1, 0 if none
* modifierIsDraft: boolean,
* isDraft: boolean, true if base revision starts with ~
* full: string, original input
* }}
*/
function parseRevision(revision) {
var raw = (revision || '').toString();
// Split on '+' to separate base from optional modifier
var plusIdx = raw.indexOf('+');
var basePart = plusIdx === -1 ? raw : raw.substring(0, plusIdx);
var modifierPart = plusIdx === -1 ? '' : raw.substring(plusIdx);
// Draft flag on the base part
var isDraft = basePart.startsWith('~');
var base = isDraft ? basePart.substring(1) : basePart;
// Parse modifier
var modifier = '';
var modifierType = '';
var modifierNumber = 0;
var modifierIsDraft = false;
if (modifierPart) {
var mMatch = modifierPart.match(MODIFIER_RE);
if (mMatch) {
modifierIsDraft = mMatch[1] === '~';
modifierType = mMatch[2].toUpperCase();
modifierNumber = parseInt(mMatch[3], 10);
modifier = modifierPart;
} else {
// Unrecognised modifier — preserve as-is
modifier = modifierPart;
}
}
return {
base: base,
modifier: modifier,
modifierType: modifierType,
modifierNumber: modifierNumber,
modifierIsDraft: modifierIsDraft,
isDraft: isDraft,
full: raw,
};
}
// ── Revision comparison ──────────────────────────────────────────────────
/**
* Classify a base revision string into a sort tier:
* 0 = date (YYYY-MM-DD)
* 1 = letter(s) A, B, AA …
* 2 = number(s) 0, 1, 2, 1.5 …
* 3 = other
*/
function _baseTier(base) {
if (/^\d{4}-\d{2}-\d{2}$/.test(base)) { return 0; }
if (/^[A-Za-z]+$/.test(base)) { return 1; }
if (/^\d+(\.\d+)?$/.test(base)) { return 2; }
return 3;
}
/**
* Compare two base revision strings.
* Sort order: dates < letters < numbers < other.
*/
function _compareBase(a, b) {
var ta = _baseTier(a);
var tb = _baseTier(b);
if (ta !== tb) { return ta - tb; }
if (ta === 0) { return a < b ? -1 : a > b ? 1 : 0; } // date lexicographic = chronological
if (ta === 1) { return a.toUpperCase() < b.toUpperCase() ? -1 : a.toUpperCase() > b.toUpperCase() ? 1 : 0; }
if (ta === 2) { return parseFloat(a) - parseFloat(b); }
return a.localeCompare(b);
}
/**
* Compare two ZDDC revision strings for sort ordering.
*
* Canonical order (ascending = older → newer):
* ~A < A < A+B1 < A+C1 < A+~C2 < A+C2 < A+N1 < A+Q1
* < ~B < B < … < 0 < 1 < 2
*
* Rules:
* 1. Compare base revisions first (dates < letters < numbers).
* 2. For equal bases, draft (isDraft=true) comes before final.
* 3. For equal base+draft, no-modifier < has-modifier.
* 4. For equal base+draft+modifier presence:
* a. modifier draft comes before modifier final (modifierIsDraft).
* b. Sort modifier by type letter then by number.
*
* @param {string} a
* @param {string} b
* @returns {number} negative if a < b, 0 if equal, positive if a > b
*/
function compareRevisions(a, b) {
var pa = parseRevision(a);
var pb = parseRevision(b);
// 1. Base revision
var baseCmp = _compareBase(pa.base, pb.base);
if (baseCmp !== 0) { return baseCmp; }
// 2. Draft before final (for same base)
if (pa.isDraft !== pb.isDraft) { return pa.isDraft ? -1 : 1; }
// 3. No modifier before any modifier
var aHasMod = pa.modifier !== '';
var bHasMod = pb.modifier !== '';
if (aHasMod !== bHasMod) { return aHasMod ? 1 : -1; }
if (!aHasMod) { return 0; } // both have no modifier
// 4. Compare modifiers: type → number → draft (draft is a tie-breaker only)
// 4a. Modifier type letter (B < C < N < Q …)
if (pa.modifierType !== pb.modifierType) {
return pa.modifierType < pb.modifierType ? -1 : 1;
}
// 4b. Modifier number (1 < 2 …)
if (pa.modifierNumber !== pb.modifierNumber) {
return pa.modifierNumber - pb.modifierNumber;
}
// 4c. Draft of a modifier comes before the final modifier (same type+number)
if (pa.modifierIsDraft !== pb.modifierIsDraft) {
return pa.modifierIsDraft ? -1 : 1;
}
return 0;
}
// ── Filename / folder formatting ─────────────────────────────────────────
/**
* Build a ZDDC filename from its components.
*
* @param {{ trackingNumber: string, revision: string, status: string,
* title: string, extension: string }} parts
* @returns {string} e.g. "123456-EL-SPC-2623_A (IFR) - Specification.pdf"
*/
function formatFilename(parts) {
var tn = (parts.trackingNumber || '').trim();
var rev = (parts.revision || '').trim();
var st = (parts.status || '').trim();
var ttl = (parts.title || '').trim();
var ext = (parts.extension || '').replace(/^\./, '');
if (!tn || !rev || !st || !ttl) { return ''; }
var name = tn + '_' + rev + ' (' + st + ') - ' + ttl;
return ext ? name + '.' + ext : name;
}
/**
* Build a ZDDC transmittal folder name from its components.
*
* @param {{ date: string, trackingNumber: string, status: string,
* title: string }} parts
* @returns {string} e.g. "2025-10-31_123456-EM-SUB-0001 (IFR) - Title"
*/
function formatFolder(parts) {
var dt = (parts.date || '').trim();
var tn = (parts.trackingNumber || '').trim();
var st = (parts.status || '').trim();
var ttl = (parts.title || '').trim();
if (!dt || !tn || !st || !ttl) { return ''; }
return dt + '_' + tn + ' (' + st + ') - ' + ttl;
}
// ── Filename / extension splitting ───────────────────────────────────────
/**
* Split a filename into its base name and extension (no leading dot).
* Treats leading dot ('.gitignore') as no extension.
*
* @param {string} filename
* @returns {{ name: string, extension: string }}
*/
function splitExtension(filename) {
if (!filename) { return { name: '', extension: '' }; }
var lastDot = filename.lastIndexOf('.');
if (lastDot <= 0) { return { name: filename, extension: '' }; }
return {
name: filename.substring(0, lastDot),
extension: filename.substring(lastDot + 1).toLowerCase(),
};
}
/**
* Join a base name and extension. Tolerant of either form ('pdf' or '.pdf').
* Returns just the name when extension is empty.
*/
function joinExtension(name, extension) {
var ext = (extension || '').replace(/^\./, '');
return ext ? name + '.' + ext : name;
}
// ── Public API ───────────────────────────────────────────────────────────
root.zddc = {
STATUSES: STATUSES,
isValidStatus: isValidStatus,
parseFilename: parseFilename,
parseFolder: parseFolder,
parseRevision: parseRevision,
formatFilename: formatFilename,
formatFolder: formatFolder,
compareRevisions: compareRevisions,
splitExtension: splitExtension,
joinExtension: joinExtension,
};
}(typeof window !== 'undefined' ? window : this));
// shared/zddc-source.js — source abstraction for tools that handle
// directory trees (classifier, mdedit, transmittal, browse, archive).
//
// Two backends:
//
// 1. Local — wraps a real FileSystemDirectoryHandle from the
// File System Access API. Reads + writes go through the
// FS Access API directly.
//
// 2. HTTP — talks to zddc-server's directory listing JSON
// (Accept: application/json) for reads and the file API
// (PUT/DELETE/POST X-ZDDC-Op) for writes. Implements a
// polyfill of the FS Access API surface area the tools
// use (kind, name, values(), getFileHandle, getDirectoryHandle,
// removeEntry, getFile, createWritable, queryPermission /
// requestPermission) so existing code works unchanged.
//
// The polyfill makes auto-load possible: when zddc-server serves
// a tool at /<dir>/<tool>.html, the tool detects HTTP mode at
// startup, builds an HttpDirectoryHandle for the tool's containing
// directory, and hands it to the existing openDirectory(handle)
// flow without ever showing the file picker.
//
// Renames inside a tool today are typically done as
// "write new + remove old". With HTTP-backed handles this becomes
// PUT + DELETE — non-atomic. Tools that prefer the atomic server
// MOVE should call window.zddc.source.moveFile(srcUrl, dstUrl)
// directly instead of going through the polyfill.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
var FA = window.FileSystemDirectoryHandle || null;
// -----------------------------------------------------------------
// HTTP file API helpers
// -----------------------------------------------------------------
function joinUrl(base, name, isDir) {
if (!base.endsWith('/')) base = base + '/';
return base + encodeURIComponent(name) + (isDir ? '/' : '');
}
// Server returns directory entries with a trailing "/" on names.
// Strip it for the FS Access API name surface.
function stripSlash(name) {
return name.endsWith('/') ? name.slice(0, -1) : name;
}
async function httpListing(url) {
var resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!resp.ok) {
var err = new Error('listing ' + url + ': HTTP ' + resp.status);
err.status = resp.status;
throw err;
}
var data = await resp.json();
if (!Array.isArray(data)) {
throw new Error('listing ' + url + ': non-array body');
}
return data;
}
async function httpExists(url) {
try {
var r = await fetch(url, { method: 'HEAD', credentials: 'same-origin' });
return r.ok;
} catch (_) {
return false;
}
}
// -----------------------------------------------------------------
// HttpFileHandle — FileSystemFileHandle polyfill
// -----------------------------------------------------------------
function makeFile(blob, name, modTime) {
return new File([blob], name, {
type: blob.type,
lastModified: modTime ? modTime.getTime() : Date.now()
});
}
function HttpFileHandle(url, name, size, modTime) {
this.kind = 'file';
this.name = name;
this._url = url;
this._size = size || 0;
this._modTime = modTime || null;
this._etag = null;
}
HttpFileHandle.prototype.getFile = async function () {
var resp = await fetch(this._url, { credentials: 'same-origin' });
if (!resp.ok) {
throw new Error('GET ' + this._url + ': ' + resp.status);
}
var etag = resp.headers.get('ETag');
if (etag) this._etag = etag.replace(/"/g, '');
var lm = resp.headers.get('Last-Modified');
var modTime = lm ? new Date(lm) : this._modTime;
var blob = await resp.blob();
return makeFile(blob, this.name, modTime);
};
HttpFileHandle.prototype.createWritable = async function () {
var chunks = [];
var handle = this;
return {
async write(data) {
if (data == null) return;
if (typeof data === 'object' && data && 'type' in data && data.type === 'write') {
chunks.push(data.data);
return;
}
if (typeof data === 'object' && data && 'type' in data) {
// seek/truncate not supported by HTTP backend
throw new Error('HttpFileHandle write op not supported: ' + data.type);
}
chunks.push(data);
},
async close() {
var blob = new Blob(chunks);
var resp = await fetch(handle._url, {
method: 'PUT',
body: blob,
credentials: 'same-origin'
});
if (!resp.ok) {
var body = '';
try { body = await resp.text(); } catch (_) { /* ignore */ }
throw new Error('PUT ' + handle._url + ': ' + resp.status + ' ' + body);
}
var et = resp.headers.get('ETag');
if (et) handle._etag = et.replace(/"/g, '');
handle._size = blob.size;
},
async abort() { chunks = []; }
};
};
HttpFileHandle.prototype.queryPermission = async function () { return 'granted'; };
HttpFileHandle.prototype.requestPermission = async function () { return 'granted'; };
HttpFileHandle.prototype.isHttp = true;
HttpFileHandle.prototype.url = function () { return this._url; };
// -----------------------------------------------------------------
// HttpDirectoryHandle — FileSystemDirectoryHandle polyfill
// -----------------------------------------------------------------
function HttpDirectoryHandle(url, name) {
this.kind = 'directory';
if (!url.endsWith('/')) url = url + '/';
this._url = url;
this.name = name || guessNameFromUrl(url);
}
function guessNameFromUrl(url) {
var u = url.replace(/\/+$/, '');
var slash = u.lastIndexOf('/');
return slash >= 0 ? decodeURIComponent(u.substring(slash + 1)) : u;
}
HttpDirectoryHandle.prototype.values = function () {
var url = this._url;
return (async function* () {
var entries;
try {
entries = await httpListing(url);
} catch (e) {
return;
}
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
var rawName = stripSlash(e.name);
var childUrl = joinUrl(url, rawName, e.is_dir);
if (e.is_dir) {
yield new HttpDirectoryHandle(childUrl, rawName);
} else {
var modTime = e.mod_time ? new Date(e.mod_time) : null;
yield new HttpFileHandle(childUrl, rawName, e.size || 0, modTime);
}
}
})();
};
HttpDirectoryHandle.prototype.entries = function () {
var iter = this.values();
return (async function* () {
for (;;) {
var step = await iter.next();
if (step.done) return;
yield [step.value.name, step.value];
}
})();
};
HttpDirectoryHandle.prototype.keys = function () {
var iter = this.values();
return (async function* () {
for (;;) {
var step = await iter.next();
if (step.done) return;
yield step.value.name;
}
})();
};
HttpDirectoryHandle.prototype.getFileHandle = async function (name, opts) {
opts = opts || {};
var url = joinUrl(this._url, name, false);
var exists = await httpExists(url);
if (!exists && !opts.create) {
var err = new Error('NotFoundError: ' + name);
err.name = 'NotFoundError';
throw err;
}
return new HttpFileHandle(url, name, 0, null);
};
HttpDirectoryHandle.prototype.getDirectoryHandle = async function (name, opts) {
opts = opts || {};
var url = joinUrl(this._url, name, true);
if (opts.create) {
var resp = await fetch(url, {
method: 'POST',
headers: { 'X-ZDDC-Op': 'mkdir' },
credentials: 'same-origin'
});
if (!resp.ok && resp.status !== 200 && resp.status !== 201) {
throw new Error('mkdir ' + url + ': ' + resp.status);
}
}
return new HttpDirectoryHandle(url, name);
};
HttpDirectoryHandle.prototype.removeEntry = async function (name, opts) {
opts = opts || {};
// Probe listing to discover whether name is a file or directory.
var entries;
try {
entries = await httpListing(this._url);
} catch (e) {
throw new Error('removeEntry probe failed: ' + e.message);
}
var match = null;
for (var i = 0; i < entries.length; i++) {
if (stripSlash(entries[i].name) === name) {
match = entries[i];
break;
}
}
if (!match) {
var err = new Error('NotFoundError: ' + name);
err.name = 'NotFoundError';
throw err;
}
if (match.is_dir && !opts.recursive) {
// Server doesn't expose a recursive-delete endpoint yet,
// and FS Access API requires recursive=true to remove a
// non-empty directory anyway. Reject explicitly so the
// caller doesn't silently leave a stale tree behind.
var derr = new Error('Removing directories over HTTP is not supported');
derr.name = 'InvalidStateError';
throw derr;
}
var url = joinUrl(this._url, name, match.is_dir);
var resp = await fetch(url, { method: 'DELETE', credentials: 'same-origin' });
if (!resp.ok && resp.status !== 204) {
throw new Error('DELETE ' + url + ': ' + resp.status);
}
};
HttpDirectoryHandle.prototype.queryPermission = async function () { return 'granted'; };
HttpDirectoryHandle.prototype.requestPermission = async function () { return 'granted'; };
HttpDirectoryHandle.prototype.isHttp = true;
HttpDirectoryHandle.prototype.url = function () { return this._url; };
// -----------------------------------------------------------------
// Top-level helpers
// -----------------------------------------------------------------
// Strip a trailing tool .html (e.g. classifier.html) from a path
// to land on the "directory the tool was opened in".
function pathToDir(pathname) {
if (!pathname) return '/';
if (pathname.endsWith('/')) return pathname;
var slash = pathname.lastIndexOf('/');
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
}
// Probe the server-mode root for the current page. Returns:
//
// { handle: HttpDirectoryHandle, status: 200 } — server reachable, listing returned
// { handle: null, status: 403 } — server reachable but listing forbidden
// { handle: null, status: 0 } — not http(s), or server unreachable / non-JSON
//
// Tools that auto-load on startup distinguish 403 (show "no
// permission to list this directory" message) from 0 (fall back
// to local-mode welcome screen).
//
// Tool init pattern:
// if (location.protocol !== 'file:') {
// const r = await zddc.source.detectServerRoot();
// if (r.handle) await openDirectory(r.handle);
// else if (r.status === 403) showNoPermissionMessage();
// else showWelcome();
// } else { showWelcome(); }
async function detectServerRoot() {
if (typeof location === 'undefined') {
return { handle: null, status: 0 };
}
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return { handle: null, status: 0 };
}
var dirPath = pathToDir(location.pathname);
var url = location.origin + dirPath;
try {
await httpListing(url);
} catch (e) {
if (e && e.status === 403) {
return { handle: null, status: 403 };
}
return { handle: null, status: 0 };
}
return {
handle: new HttpDirectoryHandle(url, guessNameFromUrl(url)),
status: 200,
};
}
// Atomic file move. Path arguments are absolute URL paths
// (starting with /). Honors the file API's POST /op=move
// contract. Returns the new ETag.
async function moveFile(srcUrlPath, dstUrlPath, opts) {
opts = opts || {};
var headers = {
'X-ZDDC-Op': 'move',
'X-ZDDC-Destination': dstUrlPath
};
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch;
var resp = await fetch(srcUrlPath, {
method: 'POST',
headers: headers,
credentials: 'same-origin'
});
if (!resp.ok) {
var body = '';
try { body = await resp.text(); } catch (_) { /* ignore */ }
throw new Error('move ' + srcUrlPath + ' → ' + dstUrlPath + ': ' + resp.status + ' ' + body);
}
var et = resp.headers.get('ETag');
return et ? et.replace(/"/g, '') : null;
}
// Detect at construction time whether a directory handle is the
// HTTP polyfill or a real FS Access API handle. Useful for tools
// that want to take the optimized path (e.g. atomic moveFile)
// when in HTTP mode rather than the FS-API copy+remove fallback.
function isHttpHandle(handle) {
return !!(handle && handle.isHttp === true);
}
window.zddc.source = {
HttpDirectoryHandle: HttpDirectoryHandle,
HttpFileHandle: HttpFileHandle,
detectServerRoot: detectServerRoot,
moveFile: moveFile,
isHttpHandle: isHttpHandle,
// Lower-level helpers exposed for tools that want to call the
// server directly without going through the polyfill.
httpListing: httpListing,
joinUrl: joinUrl,
stripSlash: stripSlash
};
})();
/**
* ZDDC shared theme toggle — light / dark / auto.
* Persists choice to localStorage under 'zddc-theme'.
* Works with all four tools regardless of their module pattern.
* Expects: #theme-btn in the DOM (optional — skips gracefully if absent).
*
* Theme cycle: auto → light → dark → auto …
* 'auto' honours the OS prefers-color-scheme media query (CSS handles it).
* 'light' sets data-theme="light" on <html> (overrides dark media query).
* 'dark' sets data-theme="dark" on <html>.
*/
(function () {
'use strict';
var STORAGE_KEY = 'zddc-theme';
var THEMES = ['auto', 'light', 'dark'];
var LABELS = {
auto: '◐',
light: '☀',
dark: '☾'
};
var TITLES = {
auto: 'Theme: auto (follows OS)',
light: 'Theme: light',
dark: 'Theme: dark'
};
function load() {
var stored = localStorage.getItem(STORAGE_KEY);
return THEMES.indexOf(stored) !== -1 ? stored : 'auto';
}
function apply(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function save(theme) {
try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
}
function updateButton(btn, theme) {
btn.textContent = LABELS[theme];
btn.title = TITLES[theme];
btn.setAttribute('aria-label', TITLES[theme]);
}
function next(theme) {
return THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
}
function init() {
var current = load();
apply(current);
var btn = document.getElementById('theme-btn');
if (!btn) { return; }
updateButton(btn, current);
btn.addEventListener('click', function () {
current = next(current);
apply(current);
save(current);
updateButton(btn, current);
});
}
/* Apply theme immediately (before DOM ready) to avoid flash */
apply(load());
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
/**
* ZDDC shared help panel — open/close logic.
* Works with all four tools regardless of their module pattern.
* Expects: #help-btn, #help-panel, #help-panel-close in the DOM.
*/
(function () {
'use strict';
function init() {
var helpBtn = document.getElementById('help-btn');
var panel = document.getElementById('help-panel');
var closeBtn = document.getElementById('help-panel-close');
if (!helpBtn || !panel) { return; }
function isOpen() { return !panel.hidden; }
function openPanel() {
panel.hidden = false;
document.body.classList.add('help-open');
}
function closePanel() {
panel.hidden = true;
document.body.classList.remove('help-open');
}
helpBtn.addEventListener('click', function () {
if (isOpen()) { closePanel(); } else { openPanel(); }
});
if (closeBtn) {
closeBtn.addEventListener('click', closePanel);
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen()) { closePanel(); }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
// mode.js — picks table-mode vs form-mode at boot time and unhides the
// matching container. Both apps (tablesApp, formApp) ship in the same
// bundle but each only paints when its container is visible.
//
// Decision rule:
// /<dir>/table.html → table mode
// /<dir>/form.html → form mode (empty / create)
// /<dir>/<id>.yaml.html → form mode (re-edit)
// anything else / file:// → table mode (legacy default; tables tool
// was the original consumer of this bundle)
//
// In offline / file:// mode the inline-context placeholders decide:
// whichever blob is non-empty wins. Tests that inject only
// #form-context render in form mode; tests that inject only
// #table-context render in table mode.
(function () {
'use strict';
function modeFromUrl() {
const path = String((typeof location !== 'undefined' && location.pathname) || '');
if (/\/form\.html$/.test(path) || /\.yaml\.html$/.test(path)) {
return 'form';
}
if (/\/table\.html$/.test(path)) {
return 'table';
}
return null; // unknown — will be decided once DOM is parsed.
}
function readInline(id) {
const el = document.getElementById(id);
if (!el) return null;
try {
return JSON.parse(el.textContent || '{}');
} catch (_) {
return null;
}
}
function modeFromInline() {
// file:// or unrecognised URL — whichever inline-context blob is
// non-empty wins. Tests that inject only #form-context render in
// form mode; tests that inject only #table-context render in
// table mode. Default to table for legacy compatibility.
const formCtx = readInline('form-context');
if (formCtx && Object.keys(formCtx).length > 0) {
return 'form';
}
return 'table';
}
// Best-effort synchronous decision so per-app boot guards can read
// window.zddcMode without waiting for DOM. URL-based decision is
// always known up-front; inline-context fallback only matters for
// file:// and is finalized at DOMContentLoaded.
window.zddcMode = modeFromUrl() || 'table';
function activate() {
if (modeFromUrl() == null) {
window.zddcMode = modeFromInline();
}
const tableEl = document.getElementById('table-mode');
const formEl = document.getElementById('form-mode');
if (window.zddcMode === 'form' && formEl) {
formEl.hidden = false;
} else if (tableEl) {
tableEl.hidden = false;
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', activate, { once: true });
} else {
activate();
}
})();
(function (global) {
'use strict';
if (global.tablesApp) {
return;
}
global.tablesApp = {
context: null,
state: {
rows: [],
sort: [],
filter: {},
// Editor-mode state (Phase 1):
// selected: {row: rowId, col: field} | null — currently
// focused cell. row is the row's id (or rowsRel for the
// row file path); col is the column's `field`.
// editing: bool — whether a cell-editor input is mounted.
// drafts: {rowId: {field: value, ...}, ...} — uncommitted
// edits, displayed in lieu of row.data while present.
// Cleared per-row when that row's PUT succeeds (Phase 3).
selected: null,
editing: false,
drafts: {}
},
modules: {}
};
})(window);
(function (app) {
'use strict';
// load() resolves to the table context the rest of the app renders:
// { title?, description?, columns, rows, defaults? }
//
// Two paths:
//
// 1. Inline JSON (test seam, and also any host that wants to
// pre-render a context server-side): if #table-context parses
// to a non-empty object, return it as-is.
//
// 2. File-backed walk (the real-world path served by zddc-server):
// page is at /<dir>/table.html — fetch <dir>/table.yaml,
// list every other *.yaml in <dir> as a row file (filtering
// out table.yaml and form.yaml so they don't appear as rows),
// parse each, and assemble the same shape. The whole table
// lives in one directory.
//
// file:// mode without a directory handle is unsupported in v1 — the
// walk only runs against http(s). file:// users must either inject an
// inline context (tests) or open the page through zddc-server.
async function load() {
const inline = readInlineContext();
if (inline && Object.keys(inline).length > 0) {
return inline;
}
if (typeof location !== 'undefined' &&
(location.protocol === 'http:' || location.protocol === 'https:')) {
try {
const walked = await walkServer();
if (walked) {
return walked;
}
} catch (err) {
console.error('[tables] failed to load table from server', err);
showStatus('Could not load table: ' + (err && err.message ? err.message : err));
}
}
return {};
}
function readInlineContext() {
const el = document.getElementById('table-context');
if (!el) {
return null;
}
try {
return JSON.parse(el.textContent || '{}');
} catch (err) {
console.error('[tables] failed to parse #table-context', err);
return null;
}
}
function showStatus(msg) {
const el = document.getElementById('table-status');
if (!el) return;
el.textContent = msg;
el.hidden = false;
}
async function walkServer() {
const source = window.zddc && window.zddc.source;
if (!source) {
throw new Error('zddc.source not available');
}
const tableName = tableNameFromUrl(location.pathname);
if (!tableName) {
throw new Error('Unrecognized table URL: ' + location.pathname);
}
const probe = await source.detectServerRoot();
if (!probe.handle) {
throw new Error(probe.status === 403
? 'No permission to list this directory'
: 'Server unreachable');
}
const dir = probe.handle;
// Spec lives at <currentdir>/table.yaml — the page URL is
// <currentdir>/table.html, so the spec is right next door.
const spec = await readYaml(dir, 'table.yaml');
if (!spec || !Array.isArray(spec.columns)) {
throw new Error('Spec table.yaml missing columns[]');
}
// Rows are every *.yaml in <currentdir> EXCEPT the spec
// (table.yaml) and the row-edit form (form.yaml). They live
// in the same directory by design — copying the directory
// copies the whole table.
const rows = await readRows(dir, '', tableName);
return {
title: spec.title,
description: spec.description,
columns: spec.columns,
defaults: spec.defaults,
rows: rows
};
}
function tableNameFromUrl(pathname) {
// /<dir>/.../<rowsdir>/table.html → name is the rows-dir's
// basename.
const m = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
return m ? m[1] : null;
}
function stripDotSlash(p) {
let out = String(p || '');
if (out.startsWith('./')) out = out.slice(2);
if (out.startsWith('/')) out = out.slice(1);
if (out.endsWith('/')) out = out.slice(0, -1);
return out;
}
async function readYaml(dir, relPath) {
const fileHandle = await resolveFile(dir, relPath);
const file = await fileHandle.getFile();
const text = await file.text();
if (!window.jsyaml) {
throw new Error('js-yaml not loaded');
}
return window.jsyaml.load(text);
}
// Walk a "/"-separated relative path under dir, returning the
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
async function resolveFile(dir, relPath) {
const parts = relPath.split('/').filter(Boolean);
if (parts.length === 0) {
throw new Error('Empty file path');
}
const fileName = parts.pop();
let cur = dir;
for (let i = 0; i < parts.length; i++) {
cur = await cur.getDirectoryHandle(parts[i]);
}
return cur.getFileHandle(fileName);
}
async function resolveDirectory(dir, relPath) {
const parts = relPath.split('/').filter(Boolean);
let cur = dir;
for (let i = 0; i < parts.length; i++) {
cur = await cur.getDirectoryHandle(parts[i]);
}
return cur;
}
async function readRows(rowsDir, _rowsRel, _tableName) {
const rows = [];
for await (const entry of rowsDir.values()) {
if (entry.kind !== 'file') continue;
if (!entry.name.endsWith('.yaml')) continue;
// Skip the spec and the row-edit form — they live alongside
// the rows but aren't rows themselves.
if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
try {
const file = await (await rowsDir.getFileHandle(entry.name)).getFile();
const data = window.jsyaml.load(await file.text());
rows.push({
url: rowEditUrl(entry.name),
data: data || {},
editable: true
});
} catch (err) {
console.warn('[tables] skipping unparseable row', entry.name, err);
}
}
return rows;
}
// Re-edit URL for one row. Page is at /<dir>/table.html; row file
// lives at /<dir>/<basename>.yaml; form re-edit URL is
// /<dir>/<basename>.yaml.html — same directory.
function rowEditUrl(rowFileName) {
const pageDir = location.pathname.replace(/\/table\.html$/, '/');
return pageDir + rowFileName + '.html';
}
app.modules.context = { load: load };
})(window.tablesApp);
(function (app) {
'use strict';
const util = {};
util.h = function (tag, attrs) {
const el = document.createElement(tag);
if (attrs) {
for (const k of Object.keys(attrs)) {
const v = attrs[k];
if (v == null || v === false) {
continue;
}
if (k === 'className') {
el.className = v;
} else if (k.length > 2 && k.slice(0, 2) === 'on' && typeof v === 'function') {
el.addEventListener(k.slice(2).toLowerCase(), v);
} else if (v === true) {
el.setAttribute(k, '');
} else {
el.setAttribute(k, v);
}
}
}
for (let i = 2; i < arguments.length; i++) {
const c = arguments[i];
if (c == null || c === false) {
continue;
}
if (typeof c === 'string' || typeof c === 'number') {
el.appendChild(document.createTextNode(String(c)));
} else {
el.appendChild(c);
}
}
return el;
};
// Resolve a column's `field` against a row data object.
// - "" or "/" → the whole object
// - "/foo/bar" → JSON Pointer (RFC 6901) lookup
// - "foo" → top-level key
util.resolveField = function (data, field) {
if (data == null) {
return undefined;
}
if (!field || field === '/') {
return data;
}
if (field.charAt(0) !== '/') {
return data[field];
}
const segments = field.split('/').slice(1).map(function (s) {
return s.replace(/~1/g, '/').replace(/~0/g, '~');
});
let cur = data;
for (let i = 0; i < segments.length; i++) {
if (cur == null) {
return undefined;
}
if (Array.isArray(cur)) {
const idx = parseInt(segments[i], 10);
if (Number.isNaN(idx)) {
return undefined;
}
cur = cur[idx];
} else if (typeof cur === 'object') {
cur = cur[segments[i]];
} else {
return undefined;
}
}
return cur;
};
// Format a raw cell value per column's `format` hint.
util.formatCell = function (value, format) {
if (value == null || value === '') {
return '';
}
if (format === 'date') {
const d = new Date(value);
if (!isNaN(d.getTime())) {
return d.toISOString().slice(0, 10);
}
return String(value);
}
if (format === 'datetime') {
const d = new Date(value);
if (!isNaN(d.getTime())) {
return d.toLocaleString();
}
return String(value);
}
if (format === 'number') {
const n = Number(value);
if (Number.isFinite(n)) {
return n.toLocaleString();
}
return String(value);
}
if (format === 'bool' || typeof value === 'boolean') {
return value ? '✓' : '';
}
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch (e) {
return String(value);
}
}
return String(value);
};
// Compare two cell values for sorting. null/undefined sort last.
// Numbers compared numerically, dates compared as Date, otherwise string compare.
util.compareCells = function (a, b, format) {
const aMissing = a == null || a === '';
const bMissing = b == null || b === '';
if (aMissing && bMissing) {
return 0;
}
if (aMissing) {
return 1;
}
if (bMissing) {
return -1;
}
if (format === 'date' || format === 'datetime') {
const da = new Date(a).getTime();
const db = new Date(b).getTime();
if (!isNaN(da) && !isNaN(db)) {
return da - db;
}
}
if (format === 'number' || (typeof a === 'number' && typeof b === 'number')) {
const na = Number(a);
const nb = Number(b);
if (Number.isFinite(na) && Number.isFinite(nb)) {
return na - nb;
}
}
const sa = String(a).toLowerCase();
const sb = String(b).toLowerCase();
if (sa < sb) return -1;
if (sa > sb) return 1;
return 0;
};
app.modules.util = util;
})(window.tablesApp);
(function (app) {
'use strict';
// A filter is per-column and has one of two shapes:
// - free-text: { kind: 'contains', value: '<string>' }
// - enum: { kind: 'enum', value: ['<choice>', ...] }
// An empty value (empty string or empty array) matches everything.
//
// The render layer only emits the free-text shape; enum is kept here
// for back-compat with any inline-context test fixtures that seed
// filter state directly. defaultFilterFor always returns text.
function isEnumColumn(col) {
return Array.isArray(col.enum) && col.enum.length > 0;
}
function defaultFilterFor(_col) {
return { kind: 'contains', value: '' };
}
function rowMatches(filter, cellValue) {
if (filter.kind === 'enum') {
if (!Array.isArray(filter.value) || filter.value.length === 0) {
return true;
}
const s = cellValue == null ? '' : String(cellValue);
return filter.value.indexOf(s) !== -1;
}
// contains
if (!filter.value) {
return true;
}
const needle = String(filter.value).toLowerCase();
const hay = cellValue == null ? '' : String(cellValue).toLowerCase();
return hay.indexOf(needle) !== -1;
}
function isEmpty(filter) {
if (filter.kind === 'enum') {
return !Array.isArray(filter.value) || filter.value.length === 0;
}
return !filter.value;
}
function apply(rows, columns, filterMap, resolveField) {
return rows.filter(function (row) {
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const filter = filterMap[col.field];
if (!filter || isEmpty(filter)) {
continue;
}
const cellValue = resolveField(row.data, col.field);
if (!rowMatches(filter, cellValue)) {
return false;
}
}
return true;
});
}
app.modules.filters = {
defaultFilterFor: defaultFilterFor,
isEnumColumn: isEnumColumn,
isEmpty: isEmpty,
apply: apply
};
})(window.tablesApp);
(function (app) {
'use strict';
// Sort state is an ordered list of {field, dir} keys; the first is
// primary, additional keys break ties.
function defaultsFromContext(ctx) {
const defaults = ctx.defaults || {};
if (Array.isArray(defaults.sort) && defaults.sort.length > 0) {
return defaults.sort.slice();
}
// Fall back to any column with `sort:` set.
const fromCols = (ctx.columns || []).filter(function (c) { return c.sort; });
if (fromCols.length > 0) {
return fromCols.map(function (c) {
const dir = c.sort === 'desc' ? 'desc' : 'asc';
return { field: c.field, dir: dir };
});
}
return [];
}
function findColumn(columns, field) {
for (let i = 0; i < columns.length; i++) {
if (columns[i].field === field) {
return columns[i];
}
}
return null;
}
// Click handler for a header: cycle the sort state for `field`.
// - Not currently a sort key → add as primary, asc
// - Currently primary asc → flip to desc
// - Currently primary desc → remove
// - Currently secondary → promote to primary, asc
// Shift-click is meant for additional accumulation but we keep the
// single-click semantics simple; advanced multi-sort can be a
// follow-up.
function cycle(state, field, multi) {
const idx = state.findIndex(function (s) { return s.field === field; });
if (multi) {
if (idx === -1) {
return state.concat([{ field: field, dir: 'asc' }]);
}
const cur = state[idx];
if (cur.dir === 'asc') {
const next = state.slice();
next[idx] = { field: field, dir: 'desc' };
return next;
}
return state.slice(0, idx).concat(state.slice(idx + 1));
}
if (idx === -1) {
return [{ field: field, dir: 'asc' }];
}
if (idx === 0) {
const cur = state[0];
if (cur.dir === 'asc') {
return [{ field: field, dir: 'desc' }];
}
return [];
}
return [{ field: field, dir: 'asc' }];
}
function apply(rows, sortState, columns, util) {
if (!sortState || sortState.length === 0) {
return rows;
}
const out = rows.slice();
out.sort(function (a, b) {
for (let i = 0; i < sortState.length; i++) {
const key = sortState[i];
const col = findColumn(columns, key.field);
const fmt = col ? col.format : '';
const av = util.resolveField(a.data, key.field);
const bv = util.resolveField(b.data, key.field);
const cmp = util.compareCells(av, bv, fmt);
if (cmp !== 0) {
return key.dir === 'desc' ? -cmp : cmp;
}
}
return 0;
});
return out;
}
function indicator(sortState, field) {
for (let i = 0; i < sortState.length; i++) {
if (sortState[i].field === field) {
const arrow = sortState[i].dir === 'desc' ? ' ▼' : ' ▲';
if (sortState.length > 1) {
return arrow + (i + 1);
}
return arrow;
}
}
return '';
}
app.modules.sort = {
defaultsFromContext: defaultsFromContext,
cycle: cycle,
apply: apply,
indicator: indicator
};
})(window.tablesApp);
(function (app) {
'use strict';
function renderHeader(theadEl, columns, sortState, filterMap, onHeaderClick, onFilterChange) {
const util = app.modules.util;
const filters = app.modules.filters;
const sort = app.modules.sort;
theadEl.innerHTML = '';
const titleRow = util.h('tr', { className: 'zddc-table__title-row' });
const filterRow = util.h('tr', { className: 'zddc-table__filter-row' });
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const indicator = sort.indicator(sortState, col.field);
const th = util.h('th', {
className: 'zddc-table__th',
'data-field': col.field,
style: col.width ? 'width:' + col.width : null,
onClick: function (ev) { onHeaderClick(col.field, ev.shiftKey); }
}, col.title || col.field, indicator);
titleRow.appendChild(th);
const td = util.h('td', { className: 'zddc-table__filter-cell' });
// Every column gets the same text-contains filter input, even
// enum columns — keeps the filter row visually uniform and
// doesn't constrain users to picking from the enum (a
// case-insensitive substring match works for both free-text
// and enum data).
const f = filterMap[col.field] || filters.defaultFilterFor(col);
const input = util.h('input', {
type: 'text',
className: 'zddc-table__filter-text',
placeholder: 'filter…',
'aria-label': 'Filter ' + (col.title || col.field),
value: typeof f.value === 'string' ? f.value : '',
onInput: function (ev) {
onFilterChange(col.field, { kind: 'contains', value: ev.target.value });
}
});
td.appendChild(input);
filterRow.appendChild(td);
}
theadEl.appendChild(titleRow);
theadEl.appendChild(filterRow);
}
function renderBody(tbodyEl, rows, columns) {
const util = app.modules.util;
tbodyEl.innerHTML = '';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const tr = util.h('tr', {
className: 'zddc-table__row' + (row.editable ? ' zddc-table__row--editable' : ' zddc-table__row--readonly'),
'data-url': row.url,
'data-editable': row.editable ? '1' : '0',
onClick: function (ev) {
const target = ev.currentTarget;
const editable = target.getAttribute('data-editable') === '1';
const url = target.getAttribute('data-url');
if (editable && url) {
// Indirection so tests can intercept without
// fighting Chromium's location.assign property
// descriptor. Production calls window.location.assign.
const nav = (window.tablesApp && window.tablesApp.navigateTo) ||
function (u) { window.location.assign(u); };
nav(url);
}
}
});
for (let c = 0; c < columns.length; c++) {
const col = columns[c];
const raw = util.resolveField(row.data, col.field);
const text = util.formatCell(raw, col.format);
tr.appendChild(util.h('td', { className: 'zddc-table__cell' }, text));
}
tbodyEl.appendChild(tr);
}
}
function renderRowCount(el, displayed, total) {
if (!el) return;
if (displayed === total) {
el.textContent = total + (total === 1 ? ' row' : ' rows');
} else {
el.textContent = displayed + ' of ' + total + ' rows';
}
}
app.modules.render = {
header: renderHeader,
body: renderBody,
rowCount: renderRowCount
};
})(window.tablesApp);
(function (app) {
'use strict';
async function init() {
// Both apps (table + form) ship in the same bundle. Skip if
// mode dispatcher said this isn't our mode — form-mode requests
// are handled by formApp.
if (window.zddcMode === 'form') {
return;
}
const ctx = await app.modules.context.load();
app.context = ctx;
const titleEl = document.getElementById('table-title');
if (ctx.title && titleEl) {
titleEl.textContent = ctx.title;
document.title = 'ZDDC — ' + ctx.title;
}
const descEl = document.getElementById('table-description');
if (descEl && ctx.description) {
descEl.textContent = ctx.description;
descEl.hidden = false;
}
const tableEl = document.getElementById('table-root');
const theadEl = tableEl.querySelector('thead');
const tbodyEl = tableEl.querySelector('tbody');
const emptyEl = document.getElementById('table-empty');
const countEl = document.getElementById('table-rowcount');
const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row');
// Add-row button: link to <name>.form.html, the form-system's
// empty-form URL for this table's row schema. POST creates a
// new submission and the server redirects to the row's edit
// URL. Hidden when we can't derive a table name from the
// pathname (e.g. inline-context test harness opening tables.html
// directly without a *.table.html URL).
if (addRowBtn) {
// Page is at <dir>/table.html; the row-creation form is at
// <dir>/form.html — same directory, just swap the basename.
if (/\/table\.html$/.test(location.pathname || '')) {
addRowBtn.href = 'form.html';
addRowBtn.hidden = false;
}
}
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
const state = app.state;
state.rows = allRows;
state.sort = app.modules.sort.defaultsFromContext(ctx);
state.filter = {};
// Seed default filters from context.defaults.filter (per-column).
if (ctx.defaults && ctx.defaults.filter && typeof ctx.defaults.filter === 'object') {
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const seeded = ctx.defaults.filter[col.field];
if (seeded == null) {
continue;
}
// Filter UI is uniformly text-contains. If the spec
// seeds an array (legacy enum-style), coerce to a
// comma-joined contains string — partial match on any
// listed value still narrows the table sensibly.
const seedStr = Array.isArray(seeded) ? seeded.join(',') : String(seeded);
state.filter[col.field] = { kind: 'contains', value: seedStr };
}
}
function anyFilterActive() {
const filters = app.modules.filters;
const keys = Object.keys(state.filter);
for (let i = 0; i < keys.length; i++) {
if (!filters.isEmpty(state.filter[keys[i]])) {
return true;
}
}
return false;
}
function paint() {
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, app.modules.util.resolveField);
const sorted = app.modules.sort.apply(filtered, state.sort, columns, app.modules.util);
app.modules.render.header(theadEl, columns, state.sort, state.filter, onHeaderClick, onFilterChange);
app.modules.render.body(tbodyEl, sorted, columns);
app.modules.render.rowCount(countEl, sorted.length, state.rows.length);
if (emptyEl) {
emptyEl.hidden = sorted.length > 0 || state.rows.length === 0;
}
if (clearBtn) {
clearBtn.hidden = !anyFilterActive();
}
}
function onHeaderClick(field, shiftKey) {
state.sort = app.modules.sort.cycle(state.sort, field, shiftKey);
paint();
}
function onFilterChange(field, value) {
state.filter[field] = value;
paint();
}
if (clearBtn) {
clearBtn.addEventListener('click', function () {
state.filter = {};
paint();
});
}
paint();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(window.tablesApp);
(function (global) {
'use strict';
if (global.formApp) {
return;
}
global.formApp = {
context: null,
rootWidget: null,
modules: {}
};
})(window);
(function (app) {
'use strict';
function load() {
const el = document.getElementById('form-context');
if (!el) {
return {};
}
try {
return JSON.parse(el.textContent || '{}');
} catch (err) {
console.error('[form] failed to parse #form-context', err);
return {};
}
}
app.modules.context = { load };
})(window.formApp);
(function (app) {
'use strict';
const util = {};
util.h = function (tag, attrs) {
const el = document.createElement(tag);
if (attrs) {
for (const k of Object.keys(attrs)) {
const v = attrs[k];
if (v == null || v === false) {
continue;
}
if (k === 'className') {
el.className = v;
} else if (k.length > 2 && k.slice(0, 2) === 'on' && typeof v === 'function') {
el.addEventListener(k.slice(2).toLowerCase(), v);
} else if (v === true) {
el.setAttribute(k, '');
} else {
el.setAttribute(k, v);
}
}
}
for (let i = 2; i < arguments.length; i++) {
const c = arguments[i];
if (c == null || c === false) {
continue;
}
if (typeof c === 'string' || typeof c === 'number') {
el.appendChild(document.createTextNode(String(c)));
} else {
el.appendChild(c);
}
}
return el;
};
// JSON Pointer (RFC 6901): encode one segment.
util.ptrEnc = function (s) {
return String(s).replace(/~/g, '~0').replace(/\//g, '~1');
};
util.ptrPush = function (path, segment) {
return path + '/' + util.ptrEnc(segment);
};
util.ptrParse = function (path) {
if (!path) {
return [];
}
return path.split('/').slice(1).map(function (s) {
return s.replace(/~1/g, '/').replace(/~0/g, '~');
});
};
let idCounter = 0;
util.uid = function (prefix) {
idCounter += 1;
return (prefix || 'f') + '-' + idCounter;
};
// Turn camelCase / snake_case into a Title Case string for default labels.
util.humanize = function (name) {
return String(name)
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, function (c) { return c.toUpperCase(); });
};
app.modules.util = util;
})(window.formApp);
(function (app) {
'use strict';
const u = app.modules.util;
// Build the standard label / description / input / help / error scaffold
// shared by all primitive widgets. Returns { wrap, errEl }.
function fieldContainer(opts) {
const wrap = u.h('div', { className: 'form-field' });
if (opts.label) {
const lbl = u.h('label', { className: 'form-field__label', for: opts.id });
lbl.appendChild(document.createTextNode(opts.label));
if (opts.required) {
lbl.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(lbl);
}
if (opts.description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, opts.description));
}
wrap.appendChild(opts.input);
if (opts.help) {
wrap.appendChild(u.h('div', { className: 'form-field__help' }, opts.help));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
return { wrap: wrap, errEl: errEl };
}
function coerceEnum(rawValue, options) {
for (let i = 0; i < options.length; i++) {
if (String(options[i]) === rawValue) {
return options[i];
}
}
return rawValue;
}
function makePrimitive(schema, ui, path, value, options) {
const id = u.uid('w');
const required = !!options.required;
const label = (ui && ui['ui:title']) || schema.title || options.fieldName || '';
const description = (ui && ui['ui:description']) || schema.description || '';
const help = (ui && ui['ui:help']) || '';
const placeholder = (ui && ui['ui:placeholder']) || '';
const widget = (ui && ui['ui:widget']) || '';
const readonly = !!(ui && ui['ui:readonly']);
const autofocus = !!(ui && ui['ui:autofocus']);
let input;
let read;
const t = schema.type;
if (t === 'boolean') {
// Render boolean as a single checkbox with an inline label, suppressing
// the standard label-above layout for cleaner UX.
const cb = u.h('input', { type: 'checkbox', id: id });
if (value === true) {
cb.checked = true;
}
if (readonly) {
cb.disabled = true;
}
const wrap = u.h('div', { className: 'form-field form-field--boolean' });
const inlineLabel = u.h('label', { for: id, className: 'form-field__checkbox-inline' });
inlineLabel.appendChild(cb);
inlineLabel.appendChild(document.createTextNode(' '));
inlineLabel.appendChild(document.createTextNode(label || ''));
if (required) {
inlineLabel.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(inlineLabel);
if (description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, description));
}
if (help) {
wrap.appendChild(u.h('div', { className: 'form-field__help' }, help));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
return widgetObject(wrap, errEl, path, function () {
return cb.checked;
});
}
if (Array.isArray(schema.enum)) {
const opts = schema.enum;
if (widget === 'radio') {
input = u.h('div', { className: 'form-field__radio-group' });
opts.forEach(function (opt, idx) {
const radioId = id + '-' + idx;
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: String(opt) });
if (value === opt) {
radio.checked = true;
}
if (readonly) {
radio.disabled = true;
}
const lbl = u.h('label', { for: radioId });
lbl.appendChild(radio);
lbl.appendChild(document.createTextNode(' ' + String(opt)));
input.appendChild(lbl);
});
read = function () {
const checked = input.querySelector('input[type="radio"]:checked');
return checked ? coerceEnum(checked.value, opts) : undefined;
};
} else {
input = u.h('select', { id: id, className: 'form-field__select' });
if (!required) {
input.appendChild(u.h('option', { value: '' }, '— select —'));
}
opts.forEach(function (opt) {
const o = u.h('option', { value: String(opt) }, String(opt));
if (value === opt) {
o.selected = true;
}
input.appendChild(o);
});
if (readonly) {
input.disabled = true;
}
read = function () {
if (input.value === '') {
return undefined;
}
return coerceEnum(input.value, opts);
};
}
} else if (t === 'number' || t === 'integer') {
input = u.h('input', {
type: 'number',
id: id,
className: 'form-field__input',
step: t === 'integer' ? '1' : 'any'
});
if (placeholder) {
input.placeholder = placeholder;
}
if (value != null) {
input.value = String(value);
}
if (readonly) {
input.readOnly = true;
}
if (autofocus) {
input.autofocus = true;
}
read = function () {
const v = input.value.trim();
if (v === '') {
return undefined;
}
const n = Number(v);
// If the user typed something non-numeric, return the raw string and
// let server validation produce a friendly error.
return Number.isFinite(n) ? n : v;
};
} else {
// Default: string-shaped input.
const fmt = schema.format;
if (widget === 'textarea') {
input = u.h('textarea', { id: id, className: 'form-field__textarea' });
} else {
let inputType = 'text';
if (fmt === 'date') {
inputType = 'date';
} else if (fmt === 'email') {
inputType = 'email';
}
input = u.h('input', { type: inputType, id: id, className: 'form-field__input' });
}
if (placeholder) {
input.placeholder = placeholder;
}
if (value != null) {
input.value = String(value);
}
if (readonly) {
input.readOnly = true;
}
if (autofocus) {
input.autofocus = true;
}
read = function () {
return input.value === '' ? undefined : input.value;
};
}
const built = fieldContainer({
id: id,
label: label,
description: description,
help: help,
required: required,
input: input
});
return widgetObject(built.wrap, built.errEl, path, read);
}
// Common widget shape used by both primitive and the wrapper above.
function widgetObject(wrapEl, errEl, path, read) {
return {
el: wrapEl,
path: path,
type: 'primitive',
read: read,
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
wrapEl.classList.add('form-field--invalid');
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
wrapEl.classList.remove('form-field--invalid');
},
child: function () { return null; }
};
}
app.modules.widgets = { makePrimitive: makePrimitive };
})(window.formApp);
(function (app) {
'use strict';
const u = app.modules.util;
function makeObject(schema, ui, path, value, options) {
const fs = u.h('fieldset', { className: 'form-fieldset' });
const label = (ui && ui['ui:title']) || schema.title || options.fieldName;
if (label) {
fs.appendChild(u.h('legend', { className: 'form-fieldset__legend' }, label));
}
if (schema.description) {
fs.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
fs.appendChild(errEl);
const props = schema.properties || {};
const requiredSet = {};
(schema.required || []).forEach(function (n) { requiredSet[n] = true; });
// Resolve render order: ui:order first (with '*' as "everything else"),
// then fall back to declaration order.
const declared = Object.keys(props);
const uiOrder = (ui && ui['ui:order']) || null;
const ordered = [];
const seen = {};
if (uiOrder && Array.isArray(uiOrder)) {
for (let i = 0; i < uiOrder.length; i++) {
const name = uiOrder[i];
if (name === '*') {
for (let j = 0; j < declared.length; j++) {
const dn = declared[j];
if (!seen[dn] && uiOrder.indexOf(dn) < 0) {
ordered.push(dn);
seen[dn] = true;
}
}
} else if (props[name] && !seen[name]) {
ordered.push(name);
seen[name] = true;
}
}
// Append anything declared but not mentioned in ui:order (and no '*' was used).
for (let j = 0; j < declared.length; j++) {
if (!seen[declared[j]]) {
ordered.push(declared[j]);
seen[declared[j]] = true;
}
}
} else {
for (let j = 0; j < declared.length; j++) {
ordered.push(declared[j]);
}
}
const children = {};
const dataObj = (value && typeof value === 'object' && !Array.isArray(value)) ? value : {};
for (let i = 0; i < ordered.length; i++) {
const name = ordered[i];
const childSchema = props[name];
const childUi = (ui && ui[name]) || {};
const childPath = u.ptrPush(path, name);
const childValue = dataObj[name];
const childWidget = app.modules.render.create(childSchema, childUi, childPath, childValue, {
fieldName: u.humanize(name),
required: !!requiredSet[name]
});
children[name] = childWidget;
fs.appendChild(childWidget.el);
}
return {
el: fs,
path: path,
type: 'object',
read: function () {
const out = {};
const keys = Object.keys(children);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const v = children[k].read();
if (v !== undefined) {
out[k] = v;
}
}
return out;
},
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
const keys = Object.keys(children);
for (let i = 0; i < keys.length; i++) {
children[keys[i]].clearErrors();
}
},
child: function (name) {
return children[name] || null;
}
};
}
app.modules.object = { makeObject: makeObject };
})(window.formApp);
(function (app) {
'use strict';
const u = app.modules.util;
function makeArray(schema, ui, path, value, options) {
const wrap = u.h('div', { className: 'form-field form-array' });
const label = (ui && ui['ui:title']) || schema.title || options.fieldName || '';
if (label) {
const lbl = u.h('label', { className: 'form-field__label' });
lbl.appendChild(document.createTextNode(label));
if (options.required) {
lbl.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(lbl);
}
if (schema.description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
const rowsEl = u.h('div', { className: 'form-array__rows' });
wrap.appendChild(rowsEl);
const itemSchema = schema.items || { type: 'string' };
const itemUi = (ui && ui.items) || {};
const uiOpts = (ui && ui['ui:options']) || {};
const addable = uiOpts.addable !== false;
const removable = uiOpts.removable !== false;
const rows = [];
function repath() {
for (let i = 0; i < rows.length; i++) {
rows[i].widget.path = u.ptrPush(path, String(i));
}
}
function addRow(rowValue) {
const idx = rows.length;
const rowPath = u.ptrPush(path, String(idx));
const childWidget = app.modules.render.create(itemSchema, itemUi, rowPath, rowValue, {
fieldName: '',
required: false
});
const rowEl = u.h('div', { className: 'form-array__row' });
const body = u.h('div', { className: 'form-array__row-body' });
body.appendChild(childWidget.el);
rowEl.appendChild(body);
if (removable) {
const actions = u.h('div', { className: 'form-array__row-actions' });
const removeBtn = u.h('button', {
type: 'button',
className: 'btn btn-small',
title: 'Remove this row',
onClick: function () { removeRow(rowEl); }
}, '×');
actions.appendChild(removeBtn);
rowEl.appendChild(actions);
}
rows.push({ widget: childWidget, rowEl: rowEl });
rowsEl.appendChild(rowEl);
}
function removeRow(targetEl) {
for (let i = 0; i < rows.length; i++) {
if (rows[i].rowEl === targetEl) {
rows.splice(i, 1);
targetEl.remove();
repath();
return;
}
}
}
const initial = Array.isArray(value) ? value : [];
for (let i = 0; i < initial.length; i++) {
addRow(initial[i]);
}
if (addable) {
const addBtn = u.h('button', {
type: 'button',
className: 'btn btn-small form-array__add',
onClick: function () { addRow(undefined); }
}, '+ Add');
wrap.appendChild(addBtn);
}
return {
el: wrap,
path: path,
type: 'array',
read: function () {
const out = [];
for (let i = 0; i < rows.length; i++) {
const v = rows[i].widget.read();
if (v !== undefined) {
out.push(v);
}
}
return out;
},
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
for (let i = 0; i < rows.length; i++) {
rows[i].widget.clearErrors();
}
},
child: function (idxStr) {
const i = parseInt(idxStr, 10);
return (rows[i] && rows[i].widget) || null;
}
};
}
app.modules.array = { makeArray: makeArray };
})(window.formApp);
(function (app) {
'use strict';
function create(schema, ui, path, value, options) {
options = options || {};
if (!schema) {
return app.modules.widgets.makePrimitive({ type: 'string' }, ui, path, value, options);
}
const t = schema.type;
if (t === 'object') {
return app.modules.object.makeObject(schema, ui, path, value, options);
}
if (t === 'array') {
return app.modules.array.makeArray(schema, ui, path, value, options);
}
// Anything else (string, number, integer, boolean, enum) falls through
// to the primitive widget which dispatches on schema.type / schema.enum.
return app.modules.widgets.makePrimitive(schema, ui, path, value, options);
}
function mount(rootEl, schema, ui, data) {
const widget = create(schema, ui, '', data, { fieldName: '', required: false });
rootEl.appendChild(widget.el);
return widget;
}
app.modules.render = { create: create, mount: mount };
})(window.formApp);
(function (app) {
'use strict';
function read() {
if (!app.rootWidget) {
return null;
}
return app.rootWidget.read();
}
app.modules.serialize = { read: read };
})(window.formApp);
(function (app) {
'use strict';
const u = app.modules.util;
function findByPath(root, path) {
if (!path || path === '') {
return root;
}
const segs = u.ptrParse(path);
let cur = root;
for (let i = 0; i < segs.length; i++) {
if (!cur || typeof cur.child !== 'function') {
return null;
}
cur = cur.child(segs[i]);
}
return cur || null;
}
function apply(errors) {
if (!errors || !errors.length || !app.rootWidget) {
return;
}
for (let i = 0; i < errors.length; i++) {
const err = errors[i];
const widget = findByPath(app.rootWidget, err.path || '');
if (widget && typeof widget.setError === 'function') {
widget.setError(err.message || 'Invalid value');
}
}
}
function clear() {
if (app.rootWidget) {
app.rootWidget.clearErrors();
}
}
app.modules.errors = { apply: apply, clear: clear };
})(window.formApp);
(function (app) {
'use strict';
function showStatus(msg, kind) {
const el = document.getElementById('form-status');
if (!el) {
return;
}
el.textContent = msg || '';
el.hidden = !msg;
el.classList.remove('is-error', 'is-success');
if (kind === 'error') {
el.classList.add('is-error');
} else if (kind === 'success') {
el.classList.add('is-success');
}
}
async function submit() {
if (!app.context || !app.context.submitUrl) {
showStatus('No submit URL configured.', 'error');
return;
}
const data = app.modules.serialize.read();
app.modules.errors.clear();
showStatus('', '');
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.disabled = true;
}
try {
const res = await fetch(app.context.submitUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.status === 200) {
showStatus('Saved.', 'success');
} else if (res.status === 201) {
const loc = res.headers.get('Location');
showStatus('Submitted.', 'success');
if (loc) {
// Capability URL for the new submission. Append .html to land
// on the form-rendered view of the just-saved data.
setTimeout(function () {
window.location.href = loc + '.html';
}, 400);
}
} else if (res.status === 422) {
let body = {};
try { body = await res.json(); } catch (e) { /* ignore */ }
app.modules.errors.apply(body.errors || []);
showStatus('Please correct the errors below.', 'error');
} else if (res.status === 403) {
showStatus('You are not allowed to submit here.', 'error');
} else if (res.status === 409) {
showStatus('A submission with this filename already exists.', 'error');
} else {
let detail = '';
try { detail = await res.text(); } catch (e) { /* ignore */ }
showStatus('Submission failed (' + res.status + ')' + (detail ? ': ' + detail : ''), 'error');
}
} catch (err) {
showStatus('Network error: ' + (err && err.message ? err.message : err), 'error');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
}
}
}
app.modules.post = { submit: submit, showStatus: showStatus };
})(window.formApp);
(function (app) {
'use strict';
function boot() {
// When this bundle is hosted by the unified tables.html, the
// mode dispatcher decides which app paints. Skip when mode is
// not "form" — table-mode requests are handled by tablesApp.
// (Standalone form/dist/form.html has no zddcMode global; treat
// undefined as form-mode for back-compat.)
if (window.zddcMode && window.zddcMode !== 'form') {
return;
}
app.context = app.modules.context.load();
if (app.context.title) {
// Standalone form.html has #form-title in its header; unified
// tables.html bundle has #table-title (shared across modes).
// Whichever exists, write to it.
const t = document.getElementById('form-title') ||
document.getElementById('table-title');
if (t) {
t.textContent = app.context.title;
}
document.title = app.context.title + ' — ZDDC';
}
const root = document.getElementById('form-root');
if (root && app.context.schema) {
app.rootWidget = app.modules.render.mount(
root,
app.context.schema,
app.context.ui || {},
app.context.data
);
}
if (app.context.errors && app.context.errors.length) {
app.modules.errors.apply(app.context.errors);
app.modules.post.showStatus('Please correct the errors below.', 'error');
}
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.addEventListener('click', app.modules.post.submit);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})(window.formApp);
</script>
</body>
</html>