Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 8s
7874 lines
401 KiB
HTML
7874 lines
401 KiB
HTML
<!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>
|
||
/* shared/fonts.css — base64-inlined woff2 for distinctive typography.
|
||
* Generated once from shared/fonts/*.woff2; do NOT edit by hand.
|
||
* Re-generate via the snippet in shared/fonts/README if you swap weights.
|
||
* "Ship the record player with the record": ZDDC tools render identically
|
||
* offline (file://) and online with no CDN dependency.
|
||
*/
|
||
|
||
@font-face {
|
||
font-family: 'IBM Plex Sans';
|
||
font-style: normal;
|
||
font-weight: 400;
|
||
font-display: swap;
|
||
src: url(data:font/woff2;base64,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) format('woff2');
|
||
}
|
||
|
||
@font-face {
|
||
font-family: 'IBM Plex Sans';
|
||
font-style: normal;
|
||
font-weight: 600;
|
||
font-display: swap;
|
||
src: url(data:font/woff2;base64,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) format('woff2');
|
||
}
|
||
|
||
@font-face {
|
||
font-family: 'Source Serif 4';
|
||
font-style: normal;
|
||
font-weight: 600;
|
||
font-display: swap;
|
||
src: url(data:font/woff2;base64,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) format('woff2');
|
||
}
|
||
|
||
/* ==========================================================================
|
||
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-display covers headings (Source Serif 4 — a refined
|
||
transitional serif that reads as "engineering / document / serious"
|
||
without being academic). --font is body UI text (IBM Plex Sans —
|
||
distinctive engineering sans, with proper figures and tabular nums).
|
||
Both are base64-inlined via shared/fonts.css; system fallbacks kick in
|
||
when fonts.css isn't loaded (e.g. unbuilt component preview). --font-mono
|
||
stays as a system stack; engineering tools rarely benefit from a custom
|
||
mono and platform mono fonts are already excellent. */
|
||
--font-display: 'Source Serif 4', ui-serif, Charter, 'Iowan Old Style', Georgia, serif;
|
||
--font: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 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: #5fa8e0;
|
||
--primary-hover: #74b6e6;
|
||
--primary-active: #88c4ec;
|
||
--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: #5fa8e0;
|
||
--primary-hover: #74b6e6;
|
||
--primary-active: #88c4ec;
|
||
--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-family: var(--font-display);
|
||
font-weight: 600;
|
||
line-height: 1.2;
|
||
/* Source Serif 4 has subtle optical sizing; let the browser opt in
|
||
where supported (modern Chromium/Firefox). */
|
||
font-optical-sizing: auto;
|
||
}
|
||
|
||
/* Tracking numbers and other engineering identifiers should align in
|
||
columns when stacked vertically. Apply tabular figures wherever we
|
||
render structured numeric data. */
|
||
table, .tabular-nums, code {
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
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 "Use 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;
|
||
/* Let the left / right groups wrap to a second row at narrow
|
||
viewports rather than overflowing the viewport edge. row-gap
|
||
gives a small breathing strip when wrapped. */
|
||
flex-wrap: wrap;
|
||
row-gap: 0.3rem;
|
||
}
|
||
|
||
/* Left and right groups inside .app-header. Both flex-row so their
|
||
children (logo, title, action button, theme icon, etc.) lay out
|
||
horizontally rather than stacking. Left side gets a slightly
|
||
larger gap because it carries the title group and an action
|
||
button; right side is just icon buttons. */
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
/* Allow the title to shrink (and ellipsize) before the action
|
||
buttons get pushed off-screen at narrow viewports. */
|
||
min-width: 0;
|
||
flex-wrap: wrap;
|
||
row-gap: 0.3rem;
|
||
}
|
||
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Title group (title + build label). Made shrinkable so narrow
|
||
viewports don't push the action buttons out of view; the title
|
||
itself ellipsizes via the rule below. */
|
||
.header-title-group {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.5rem;
|
||
min-width: 0;
|
||
flex-shrink: 1;
|
||
}
|
||
|
||
/* Tool name inside the header. Renders in the display serif so the
|
||
tool's identity reads as a document title, not a UI label.
|
||
overflow + ellipsis on min-width:0 lets the title compress
|
||
gracefully when there's no room. */
|
||
.app-header__title {
|
||
font-family: var(--font-display);
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
letter-spacing: 0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* Page-load reveal. The header is the first thing a user sees — a
|
||
short staggered fade-in over ~360ms turns "instant pop-in" into a
|
||
subtle "the tool is composing itself for you" beat. Pure CSS, no
|
||
JS; respects prefers-reduced-motion. The stagger order (logo →
|
||
title → action buttons → right-side icons) mirrors the reading
|
||
order of the chrome itself. */
|
||
@keyframes zddc-header-rise {
|
||
from { opacity: 0; transform: translateY(-4px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
.app-header__logo,
|
||
.header-title-group,
|
||
.header-left > .btn,
|
||
.header-right > * {
|
||
animation: zddc-header-rise 360ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||
}
|
||
|
||
.app-header__logo { animation-delay: 0ms; }
|
||
.header-title-group { animation-delay: 60ms; }
|
||
.header-left > .btn { animation-delay: 120ms; }
|
||
.header-right > *:nth-child(1) { animation-delay: 180ms; }
|
||
.header-right > *:nth-child(2) { animation-delay: 220ms; }
|
||
.header-right > *:nth-child(3) { animation-delay: 260ms; }
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.app-header__logo,
|
||
.header-title-group,
|
||
.header-left > .btn,
|
||
.header-right > * {
|
||
animation: none;
|
||
}
|
||
}
|
||
|
||
/* ── 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;
|
||
}
|
||
|
||
/* The refresh ⟳ glyph renders slightly smaller than ◐ / ? — bump to match. */
|
||
#refreshHeaderBtn {
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
/* Toast CSS lives in shared/toast.css; loaded by every tool's build. */
|
||
|
||
/* ── Empty state ──────────────────────────────────────────────────────────── */
|
||
/* The "nothing's loaded yet" screen. By default, centers its inner
|
||
content in whatever space the parent gives it (works inside a flex
|
||
column). Tools that need to overlay an existing layout (archive,
|
||
classifier) add .empty-state--overlay; the screen pins below the
|
||
app header and on top of whatever underlying layout already exists.
|
||
Inner content uses BEM-ish .empty-state__inner with two variants:
|
||
plain (left-aligned, doc-style) and --centered (centered card). */
|
||
|
||
.empty-state {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 2rem;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.empty-state--overlay {
|
||
position: absolute;
|
||
top: 50px; /* clear the app-header */
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 10;
|
||
flex: none;
|
||
}
|
||
|
||
.empty-state__inner {
|
||
max-width: 640px;
|
||
color: var(--text-muted);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.empty-state__inner h2 {
|
||
color: var(--text);
|
||
margin: 0 0 1rem;
|
||
font-size: 1.5rem;
|
||
}
|
||
|
||
.empty-state__inner p {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.empty-state__inner ul,
|
||
.empty-state__inner ol {
|
||
margin: 1rem 0;
|
||
padding-left: 1.5rem;
|
||
}
|
||
|
||
.empty-state__inner li {
|
||
margin: 0.4rem 0;
|
||
}
|
||
|
||
.empty-state__inner .note {
|
||
font-size: 0.85rem;
|
||
font-style: italic;
|
||
}
|
||
|
||
/* Centered variant: tighter max-width + centered text. Used by tools
|
||
whose empty-state reads as a "welcome card" (archive, classifier)
|
||
rather than a doc-style page (browse). */
|
||
.empty-state__inner--centered {
|
||
max-width: 500px;
|
||
text-align: center;
|
||
padding: 2rem;
|
||
}
|
||
|
||
/* Bullet list inside an empty-state — keep the bullets left-aligned
|
||
even when the surrounding card is centered. */
|
||
.welcome-list {
|
||
text-align: left;
|
||
margin: 0.5rem auto;
|
||
max-width: 400px;
|
||
}
|
||
|
||
/* ── 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);
|
||
}
|
||
|
||
/* ── Narrow-viewport behavior ─────────────────────────────────────────────────
|
||
ZDDC tools are desktop-first (engineering workstations, large monitors),
|
||
but a baseline narrow rule keeps them usable on a tablet in landscape or
|
||
a window split next to a document. Three principled moves:
|
||
|
||
1. Smaller header padding so the chrome doesn't dominate the viewport.
|
||
2. The build-timestamp inside .header-title-group is hidden — it's a
|
||
traceability artifact, never an immediate-action element. (The full
|
||
label remains visible via the help panel and the "About" surface.)
|
||
3. .header-right gap tightens; the action button next to the title
|
||
drops to a 32x32 icon-only square via the .btn-square pattern (tools
|
||
that haven't adopted .btn-square just keep the text button — graceful).
|
||
|
||
Each tool is welcome to add its own narrow-mode rules in css/layout.css;
|
||
this block is the shared baseline. */
|
||
@media (max-width: 800px) {
|
||
.app-header {
|
||
padding: 0.3rem 0.6rem;
|
||
}
|
||
.app-header__title {
|
||
font-size: 16px;
|
||
}
|
||
.header-left {
|
||
gap: 0.5rem;
|
||
}
|
||
.header-right {
|
||
gap: 0.25rem;
|
||
}
|
||
/* Hide the build-timestamp on narrow viewports — it's reference info,
|
||
not a primary affordance, and steals horizontal space from the title.
|
||
Still reachable via the help panel and DOM. */
|
||
.header-title-group .build-timestamp {
|
||
display: none;
|
||
}
|
||
/* Action buttons that have an emoji-only or symbol-only label keep
|
||
their full width; text-labeled action buttons in the header shrink
|
||
to a more compact pad to fit. */
|
||
.header-left > .btn {
|
||
padding: 0.3rem 0.6rem;
|
||
font-size: 0.85rem;
|
||
}
|
||
}
|
||
|
||
/* Very narrow (phone-width). Stack the header-left children vertically so
|
||
the title and action button each get their own line; tools can override
|
||
this in their own CSS if they have a dedicated mobile layout. */
|
||
@media (max-width: 480px) {
|
||
.app-header {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
.header-left,
|
||
.header-right {
|
||
width: 100%;
|
||
justify-content: space-between;
|
||
}
|
||
}
|
||
|
||
/* shared/toast.css — single-toast notification styles paired with
|
||
shared/toast.js. Uses BEM-ish .zddc-toast prefix to avoid collisions
|
||
with tool-local .toast classes; the old classifier rules can stay
|
||
alongside until this file is concatenated above them in the build. */
|
||
|
||
.zddc-toast {
|
||
position: fixed;
|
||
bottom: 2rem;
|
||
right: 2rem;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
padding: 0.875rem 1.25rem;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
z-index: 9000;
|
||
max-width: 400px;
|
||
font-size: 0.875rem;
|
||
cursor: pointer;
|
||
animation: zddc-toast-in 0.3s ease-out;
|
||
}
|
||
|
||
.zddc-toast--success { border-left: 4px solid var(--success); }
|
||
.zddc-toast--error { border-left: 4px solid var(--danger); }
|
||
.zddc-toast--info { border-left: 4px solid var(--info); }
|
||
.zddc-toast--warning { border-left: 4px solid var(--warning); }
|
||
|
||
.zddc-toast--fade {
|
||
animation: zddc-toast-out 0.3s ease-out forwards;
|
||
}
|
||
|
||
@keyframes zddc-toast-in {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
|
||
@keyframes zddc-toast-out {
|
||
from { transform: translateX(0); opacity: 1; }
|
||
to { transform: translateX(100%); opacity: 0; }
|
||
}
|
||
|
||
/* Inline action button appended to a toast by zddc.cap.handleForbidden
|
||
when an Elevate path is offered. Stops click propagation on its own
|
||
so clicking the button doesn't also dismiss the toast. */
|
||
.zddc-toast__action {
|
||
display: inline-block;
|
||
margin-left: 0.75rem;
|
||
padding: 0.25rem 0.75rem;
|
||
background: var(--accent, var(--text));
|
||
color: var(--bg);
|
||
border: none;
|
||
border-radius: var(--radius);
|
||
font-size: 0.8125rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
}
|
||
.zddc-toast__action:hover {
|
||
filter: brightness(1.1);
|
||
}
|
||
|
||
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||
Renders only for users with admin scope (handled by elevation.js;
|
||
the placeholder is `.hidden` by default). When visible, sits left
|
||
of the theme button — sudo-style affordance for opting into admin
|
||
powers. */
|
||
|
||
.elevation-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
font-size: 0.78rem;
|
||
color: var(--text-muted);
|
||
user-select: none;
|
||
cursor: pointer;
|
||
padding: 0.15rem 0.45rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--bg);
|
||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||
}
|
||
|
||
.elevation-toggle:hover {
|
||
background: var(--bg-hover);
|
||
border-color: var(--border-dark);
|
||
}
|
||
|
||
.elevation-toggle input[type="checkbox"] {
|
||
margin: 0;
|
||
cursor: pointer;
|
||
accent-color: var(--danger);
|
||
}
|
||
|
||
.elevation-toggle__label {
|
||
cursor: pointer;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
/* Active state — when elevation is ON, the toggle reads as "armed"
|
||
so the user can't miss that admin powers are currently live.
|
||
:has(:checked) lets us style the wrapper based on the inner
|
||
checkbox without JS. */
|
||
.elevation-toggle:has(input:checked) {
|
||
background: rgba(220, 53, 69, 0.12);
|
||
border-color: var(--danger);
|
||
color: var(--danger);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||
easy to miss; these add an inescapable visual cue:
|
||
1. Thin red border around the entire viewport — peripheral-
|
||
vision reminder regardless of which tool / scroll position.
|
||
2. Sticky banner across the top with a one-click "Drop admin"
|
||
button so the user can disarm without hunting for the toggle.
|
||
Both rendered ONLY when the zddc-elevate cookie is set; the
|
||
shared/elevation.js init() syncs the body class on every page
|
||
load and tears it down when elevation is cleared.
|
||
|
||
Frame uses fixed positioning + pointer-events:none so it doesn't
|
||
reflow content or steal clicks. An inset outline on <body> was
|
||
tried first but overdrew content in tools whose root layout butts
|
||
right up to the viewport edge (browse split-pane, archive grid). */
|
||
body.is-elevated::after {
|
||
content: "";
|
||
position: fixed;
|
||
inset: 0;
|
||
border: 3px solid var(--danger, #dc3545);
|
||
pointer-events: none;
|
||
z-index: 9200; /* above banner (9100) so the frame paints on top */
|
||
}
|
||
|
||
.elevation-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 0.4rem 0.9rem;
|
||
background: rgba(220, 53, 69, 0.95);
|
||
color: #fff;
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
letter-spacing: 0.01em;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
||
}
|
||
|
||
.elevation-banner__dot {
|
||
width: 0.5rem;
|
||
height: 0.5rem;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
|
||
animation: elev-pulse 1.6s infinite;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
@keyframes elev-pulse {
|
||
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
|
||
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
|
||
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
|
||
}
|
||
|
||
.elevation-banner__msg {
|
||
flex: 1 1 auto;
|
||
}
|
||
|
||
.elevation-banner__off {
|
||
background: rgba(255, 255, 255, 0.18);
|
||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||
color: #fff;
|
||
padding: 0.18rem 0.65rem;
|
||
border-radius: var(--radius, 4px);
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.02em;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
.elevation-banner__off:hover {
|
||
background: rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||
inherits the logo's box and adds a subtle hover/focus affordance
|
||
so it reads as clickable without altering the logo's visual weight. */
|
||
|
||
.app-header__logo-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
text-decoration: none;
|
||
border-radius: var(--radius);
|
||
transition: opacity 0.15s, box-shadow 0.15s;
|
||
}
|
||
|
||
.app-header__logo-link:hover .app-header__logo,
|
||
.app-header__logo-link:focus-visible .app-header__logo {
|
||
opacity: 0.82;
|
||
}
|
||
|
||
.app-header__logo-link:focus-visible {
|
||
outline: 2px solid var(--primary);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* shared/context-menu.css — generic styles for window.zddc.menu.
|
||
Mirrors the look-and-feel of native context menus: tight rows,
|
||
five-column grid (check | icon | label | accel | arrow), subtle
|
||
border + shadow, hover background from the shared --bg-hover token,
|
||
danger items tinted with --danger. */
|
||
|
||
.zddc-menu {
|
||
position: fixed;
|
||
z-index: 10000;
|
||
min-width: 12rem;
|
||
max-width: 22rem;
|
||
padding: 0.25rem 0;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18),
|
||
0 2px 6px rgba(0, 0, 0, 0.10);
|
||
font-family: var(--font);
|
||
font-size: 0.85rem;
|
||
line-height: 1.2;
|
||
user-select: none;
|
||
/* Allow focus styles inside without leaking to the menu itself. */
|
||
outline: none;
|
||
}
|
||
|
||
.zddc-menu__sep {
|
||
height: 1px;
|
||
margin: 0.25rem 0;
|
||
background: var(--border);
|
||
}
|
||
|
||
.zddc-menu__item {
|
||
display: grid;
|
||
grid-template-columns: 1.1rem 1.25rem 1fr auto 0.9rem;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
padding: 0.3rem 0.7rem;
|
||
cursor: pointer;
|
||
color: var(--text);
|
||
/* Suppress the focus ring on the row itself — hover/focus
|
||
background handles the cue. */
|
||
outline: none;
|
||
}
|
||
|
||
.zddc-menu__item:hover,
|
||
.zddc-menu__item:focus,
|
||
.zddc-menu__item:focus-visible {
|
||
background: var(--bg-hover);
|
||
}
|
||
|
||
.zddc-menu__item.is-disabled {
|
||
color: var(--text-muted);
|
||
cursor: default;
|
||
}
|
||
|
||
.zddc-menu__item.is-disabled:hover,
|
||
.zddc-menu__item.is-disabled:focus {
|
||
background: transparent;
|
||
}
|
||
|
||
.zddc-menu__item--danger {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.zddc-menu__item--danger:hover,
|
||
.zddc-menu__item--danger:focus {
|
||
background: var(--danger);
|
||
color: var(--text-light);
|
||
}
|
||
|
||
.zddc-menu__check {
|
||
font-size: 0.9rem;
|
||
text-align: center;
|
||
color: var(--primary);
|
||
}
|
||
|
||
.zddc-menu__icon {
|
||
font-size: 0.95rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.zddc-menu__label {
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.zddc-menu__accel {
|
||
color: var(--text-muted);
|
||
font-size: 0.78rem;
|
||
font-variant-numeric: tabular-nums;
|
||
padding-left: 0.5rem;
|
||
}
|
||
|
||
.zddc-menu__item--danger .zddc-menu__accel {
|
||
color: inherit;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.zddc-menu__arrow {
|
||
color: var(--text-muted);
|
||
font-size: 0.7rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.zddc-menu__item--has-sub .zddc-menu__arrow {
|
||
color: var(--text);
|
||
}
|
||
|
||
/* 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));
|
||
}
|
||
|
||
/* Minimum row height so a freshly-added row (every cell empty) stays
|
||
visible — without this the row collapses to just cell padding and
|
||
looks like a thin divider line. Acts as a floor; rows with content
|
||
grow naturally to fit the text. */
|
||
.zddc-table__row {
|
||
height: 2.4em;
|
||
}
|
||
|
||
.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;
|
||
cursor: cell;
|
||
/* Hide the browser's default outline; the grid pattern renders
|
||
its own selection chrome via the --selected class. */
|
||
outline: none;
|
||
}
|
||
|
||
/* Currently-selected cell — Excel-style focus ring. The 2px outset
|
||
border doesn't push surrounding cells around because outline is
|
||
used instead of border. */
|
||
.zddc-table__cell--selected {
|
||
outline: 2px solid var(--color-accent, #2868c8);
|
||
outline-offset: -2px;
|
||
background: var(--color-bg-selected, rgba(40, 104, 200, 0.08));
|
||
}
|
||
|
||
/* Cells in the multi-cell range get a fainter highlight; the focus
|
||
cell (the one with --selected) stays brighter so the anchor /
|
||
focus distinction is visible. */
|
||
.zddc-table__cell--in-range:not(.zddc-table__cell--selected) {
|
||
background: var(--color-bg-range, rgba(40, 104, 200, 0.05));
|
||
}
|
||
|
||
/* Inline cell-editor input: occupies the cell verbatim, no border so
|
||
it visually replaces the cell text. The selected outline on the
|
||
surrounding td still shows. */
|
||
.zddc-table__cell-input {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
padding: 0;
|
||
margin: 0;
|
||
border: none;
|
||
background: var(--color-bg, #fff);
|
||
color: var(--color-text, #111);
|
||
font: inherit;
|
||
outline: none;
|
||
}
|
||
|
||
/* Row-save state markers (Phase 3). The first cell of the row gets a
|
||
left-border swatch; the row tooltip on hover surfaces the state.
|
||
Colors track the state's urgency: dirty (subtle), saving (info),
|
||
queued (warm), invalid/stale (warning), errored (alert). */
|
||
/* Dirty row gets a wider swatch (4px → easier to see at a glance) AND
|
||
a faint blue background so the unsaved state reads as "row is in a
|
||
different state" not "small marker on the edge". */
|
||
.zddc-table__row--dirty td:first-child { box-shadow: inset 4px 0 0 var(--color-info, #4a90e2); }
|
||
.zddc-table__row--dirty { background: var(--color-bg-info, rgba(74, 144, 226, 0.08)); }
|
||
.zddc-table__row--saving td:first-child { box-shadow: inset 3px 0 0 var(--color-muted, #888); }
|
||
.zddc-table__row--queued td:first-child { box-shadow: inset 3px 0 0 var(--color-warm, #d4a017); }
|
||
.zddc-table__row--stale td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); background: var(--color-bg-warning, rgba(232, 163, 61, 0.06)); }
|
||
.zddc-table__row--invalid td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); }
|
||
.zddc-table__row--errored td:first-child { box-shadow: inset 3px 0 0 var(--color-error, #c14242); background: var(--color-bg-error, rgba(193, 66, 66, 0.06)); }
|
||
|
||
/* Per-cell invalid marker — small red corner triangle, Excel-style.
|
||
The hover tooltip carries the validation message via title attr. */
|
||
.zddc-table__cell--invalid {
|
||
position: relative;
|
||
}
|
||
.zddc-table__cell--invalid::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
width: 0;
|
||
height: 0;
|
||
border-style: solid;
|
||
border-width: 0 6px 6px 0;
|
||
border-color: transparent var(--color-error, #c14242) transparent transparent;
|
||
}
|
||
|
||
/* Status bar (table-status) when used as the stale-row prompt host. */
|
||
.table-status.table-status--prompt {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
background: var(--color-bg-warning, rgba(232, 163, 61, 0.08));
|
||
border: 1px solid var(--color-warning, #e8a33d);
|
||
border-radius: var(--radius-sm, 4px);
|
||
margin-bottom: var(--spacing-sm);
|
||
color: var(--color-text, #111);
|
||
}
|
||
|
||
.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.
|
||
Form-specific layout only; theme tokens (--primary, --bg, --text,
|
||
--border, --bg-secondary, --text-muted, --font-mono, --radius) come
|
||
from shared/base.css. Button styles (.btn, .btn-primary,
|
||
.btn-secondary, .btn-sm) likewise inherit from shared. */
|
||
|
||
.form-main {
|
||
max-width: 800px;
|
||
margin: 1.5rem auto;
|
||
padding: 0 1rem 4rem;
|
||
}
|
||
|
||
.form-status {
|
||
padding: 0.75rem 1rem;
|
||
margin-bottom: 1rem;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.form-status.is-error {
|
||
background: var(--bg-secondary);
|
||
border-color: var(--danger);
|
||
color: var(--danger);
|
||
}
|
||
|
||
.form-status.is-success {
|
||
background: var(--bg-secondary);
|
||
border-color: var(--success);
|
||
color: var(--success);
|
||
}
|
||
|
||
.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: var(--danger);
|
||
margin-left: 0.15rem;
|
||
}
|
||
|
||
.form-field__description {
|
||
font-size: 0.85rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.form-field__error {
|
||
font-size: 0.85rem;
|
||
color: var(--danger);
|
||
margin-top: 0.15rem;
|
||
}
|
||
|
||
.form-field__help {
|
||
font-size: 0.8rem;
|
||
color: var(--text-muted);
|
||
font-style: italic;
|
||
}
|
||
|
||
.form-field__input,
|
||
.form-field__textarea,
|
||
.form-field__select {
|
||
padding: 0.5rem 0.65rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
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(--primary);
|
||
outline-offset: -1px;
|
||
}
|
||
|
||
.form-field--invalid .form-field__input,
|
||
.form-field--invalid .form-field__textarea,
|
||
.form-field--invalid .form-field__select {
|
||
border-color: var(--danger);
|
||
}
|
||
|
||
.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(--border);
|
||
border-radius: var(--radius);
|
||
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(--border);
|
||
border-radius: var(--radius);
|
||
padding: 0.5rem;
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* Standalone welcome — shown when form.html is opened directly (no
|
||
server-injected #form-context). */
|
||
.form-welcome {
|
||
max-width: 36rem;
|
||
margin: 2rem auto;
|
||
padding: 1.5rem 1.75rem;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
}
|
||
.form-welcome h2 {
|
||
margin-bottom: 0.5rem;
|
||
font-size: 1.25rem;
|
||
}
|
||
.form-welcome h3 {
|
||
margin: 1rem 0 0.35rem;
|
||
font-size: 0.95rem;
|
||
}
|
||
.form-welcome p { margin-bottom: 0.75rem; line-height: 1.5; }
|
||
.form-welcome ol { margin: 0 0 0.75rem 1.25rem; }
|
||
.form-welcome li { margin-bottom: 0.35rem; }
|
||
.form-welcome code {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.85em;
|
||
background: var(--bg-secondary);
|
||
padding: 0.05em 0.3em;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
</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.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span>
|
||
</div>
|
||
</div>
|
||
<div class="header-right">
|
||
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||
when /.profile/access reports the user has admin
|
||
authority; stays empty + hidden for non-admins so
|
||
the chrome is quiet for the common case. -->
|
||
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||
<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">
|
||
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
|
||
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
|
||
<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">×</button>
|
||
</div>
|
||
<div class="help-panel__body">
|
||
<h3>What is this table?</h3>
|
||
<p>The directory you opened — say <code>archive/Acme/mdl/</code> —
|
||
<em>is</em> the table. <code>table.yaml</code> describes the
|
||
columns; <code>form.yaml</code> describes the row-edit form
|
||
schema; every other <code>.yaml</code> file in the directory
|
||
is one row. Copying the directory anywhere takes the whole
|
||
table (spec + form + every row) with it.</p>
|
||
|
||
<h3>Editing cells</h3>
|
||
<p>Click a cell to select it. Then:</p>
|
||
<dl>
|
||
<dt><kbd>↑</kbd> / <kbd>↓</kbd> / <kbd>←</kbd> / <kbd>→</kbd></dt>
|
||
<dd>Move selection. Hold <kbd>Shift</kbd> to extend a range.</dd>
|
||
<dt><kbd>Tab</kbd> / <kbd>Shift+Tab</kbd></dt>
|
||
<dd>Move right / left, wrap to next / previous row.</dd>
|
||
<dt><kbd>Enter</kbd> / <kbd>F2</kbd> / double-click / typing</dt>
|
||
<dd>Enter edit mode. Typing replaces the cell value; the
|
||
others keep it.</dd>
|
||
<dt><kbd>Enter</kbd> in edit mode</dt>
|
||
<dd>Commit and move down.</dd>
|
||
<dt><kbd>Tab</kbd> in edit mode</dt>
|
||
<dd>Commit and move right.</dd>
|
||
<dt><kbd>Esc</kbd></dt>
|
||
<dd>Cancel the edit; restore the prior value.</dd>
|
||
<dt><kbd>Delete</kbd> / <kbd>Backspace</kbd></dt>
|
||
<dd>Clear every cell in the current selection.</dd>
|
||
<dt><kbd>Ctrl+D</kbd> / <kbd>Ctrl+R</kbd></dt>
|
||
<dd>Fill the top row down / left column right through the
|
||
selected range.</dd>
|
||
<dt><kbd>Ctrl+C</kbd> / <kbd>Ctrl+V</kbd></dt>
|
||
<dd>Copy / paste — interoperates with Excel and Google
|
||
Sheets via tab-separated values.</dd>
|
||
<dt><kbd>Ctrl+Z</kbd></dt>
|
||
<dd>Undo the last edit (one history per session).</dd>
|
||
</dl>
|
||
<p>Edits save automatically when you move to a different row.
|
||
A small left-edge swatch on the row indicates state:
|
||
<strong>blue</strong> = unsaved, <strong>amber</strong> = the
|
||
server flagged a validation error, <strong>orange</strong> =
|
||
someone else changed this row since you loaded it (you'll
|
||
get a prompt with <em>Use mine</em> / <em>Reload</em>).</p>
|
||
|
||
<h3>Sorting</h3>
|
||
<p>Click a column header to sort by that column. Click again to
|
||
toggle direction. <kbd>Shift</kbd>-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). Same filter UI
|
||
for every column.</p>
|
||
|
||
<h3>Customizing the columns</h3>
|
||
<p>The default Master Deliverables List has columns for every
|
||
component of a tracking number
|
||
(<code>originator</code>, <code>phase</code>,
|
||
<code>project</code>, <code>area</code>,
|
||
<code>discipline</code>, <code>type</code>,
|
||
<code>sequence</code>, <code>suffix</code>) plus deliverable
|
||
metadata. To customize, drop your own
|
||
<code>table.yaml</code> (and matching
|
||
<code>form.yaml</code>) into this directory:</p>
|
||
<pre><code>archive/<party>/mdl/
|
||
table.yaml ← columns + sort/filter defaults
|
||
form.yaml ← per-row schema (JSON Schema)
|
||
<id>.yaml ... ← rows</code></pre>
|
||
<p>Operator-supplied files override the embedded defaults.
|
||
Hide a column by omitting it from <code>columns:</code>;
|
||
add a column by appending one (and adding the matching
|
||
property in <code>form.yaml</code>'s
|
||
<code>schema.properties</code>). The same pattern works
|
||
for any directory — <code><dir>/table.html</code>
|
||
is automatically a table whenever
|
||
<code><dir>/table.yaml</code> exists.</p>
|
||
|
||
<h3>Permissions</h3>
|
||
<p>Whether a row is editable depends on the cascading
|
||
<code>.zddc</code> permissions for the directory. 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',
|
||
'TBD',
|
||
];
|
||
|
||
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, 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);
|
||
// Listing entries can carry an explicit URL for virtual
|
||
// links (e.g. the reviewing-aggregator's received/+staged/
|
||
// entries point to canonical archive/+staging paths).
|
||
// Use it when present so navigation follows the listing's
|
||
// own routing rather than computing a synthetic child URL
|
||
// off the parent. Caddy-shape listings don't set url
|
||
// (or set it to a relative form) — joinUrl handles those.
|
||
var childUrl;
|
||
if (e.url && /^https?:\/\/|^\//.test(e.url)) {
|
||
// Absolute or root-relative: use as-is, normalised against origin.
|
||
var u = e.url;
|
||
if (u[0] === '/') {
|
||
u = location.origin + u;
|
||
}
|
||
childUrl = u;
|
||
} else {
|
||
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
|
||
// -----------------------------------------------------------------
|
||
|
||
// Resolve "the directory the tool was opened in" for the current
|
||
// page URL. Two URL shapes serve a tool:
|
||
//
|
||
// /…/<tool>.html — file URL; strip the trailing filename.
|
||
// /…/<dir>/ — trailing-slash directory URL; keep it.
|
||
// /…/<dir> — bare-directory URL served by the
|
||
// cascade's `default_tool` (e.g.
|
||
// archive/<party>/mdl serves the tables
|
||
// tool). Treat as the directory itself
|
||
// and append the missing slash.
|
||
//
|
||
// Discrimination is "does the last segment contain a dot?" — a dot
|
||
// is a reliable proxy for "looks like a file with an extension"
|
||
// since neither directory names nor default_tool paths contain
|
||
// them in this system.
|
||
function pathToDir(pathname) {
|
||
if (!pathname) return '/';
|
||
if (pathname.endsWith('/')) return pathname;
|
||
var slash = pathname.lastIndexOf('/');
|
||
var lastSeg = slash >= 0 ? pathname.substring(slash + 1) : pathname;
|
||
if (lastSeg.indexOf('.') !== -1) {
|
||
// Has an extension → looks like a file URL → strip the
|
||
// filename to land on the parent directory.
|
||
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
|
||
}
|
||
// No extension → the URL IS the directory; just close it.
|
||
return pathname + '/';
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// downloadConverted fetches a server-side MD→{docx,html,pdf}
|
||
// conversion and triggers a browser download with a clean filename.
|
||
// srcUrl points at the .md source on the server. fmt is one of
|
||
// "docx" | "html" | "pdf". The server response status maps to a
|
||
// friendly error message for the caller to surface (toast / status).
|
||
//
|
||
// URL grammar: srcUrl is the `<file>.md` source; the converted
|
||
// form lives at `<file>.<fmt>` (virtual file extension recognised
|
||
// by zddc-server's dispatcher). Replaces the older `?convert=`
|
||
// query form.
|
||
async function downloadConverted(srcUrl, fileName, fmt) {
|
||
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
|
||
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
|
||
if (!resp.ok) {
|
||
var msg;
|
||
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
||
else if (resp.status === 422) msg = 'Conversion failed — the source may be malformed.';
|
||
else if (resp.status === 504) msg = 'Conversion timed out.';
|
||
else msg = 'Conversion failed (HTTP ' + resp.status + ').';
|
||
// Append server-supplied body text if it adds detail.
|
||
try {
|
||
var detail = await resp.text();
|
||
if (detail && detail.length < 400) msg += ' ' + detail.trim();
|
||
} catch (_) { /* ignore */ }
|
||
throw new Error(msg);
|
||
}
|
||
var blob = await resp.blob();
|
||
var a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = fileName.replace(/\.md$/i, '') + '.' + fmt;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
||
}
|
||
|
||
window.zddc.source = {
|
||
HttpDirectoryHandle: HttpDirectoryHandle,
|
||
HttpFileHandle: HttpFileHandle,
|
||
detectServerRoot: detectServerRoot,
|
||
moveFile: moveFile,
|
||
isHttpHandle: isHttpHandle,
|
||
downloadConverted: downloadConverted,
|
||
// 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();
|
||
}
|
||
}());
|
||
|
||
// shared/toast.js — non-blocking notification helper available to every
|
||
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
|
||
// local showToast (classifier/js/excel.js); promoted here so tools that
|
||
// today use alert() or silent console.error can switch to a uniform
|
||
// non-blocking surface.
|
||
//
|
||
// Usage:
|
||
// window.zddc.toast('Saved.', 'success');
|
||
// window.zddc.toast('Could not load: ' + err.message, 'error');
|
||
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
|
||
//
|
||
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
|
||
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
|
||
// see ARCHITECTURE.md for the convention.
|
||
(function () {
|
||
'use strict';
|
||
|
||
if (!window.zddc) window.zddc = {};
|
||
// Don't overwrite if a tool defined its own first.
|
||
if (typeof window.zddc.toast === 'function') return;
|
||
|
||
var DEFAULT_DURATION_MS = 5000;
|
||
var FADE_MS = 300;
|
||
|
||
function toast(message, level, opts) {
|
||
opts = opts || {};
|
||
var lvl = (level === 'success' || level === 'error' ||
|
||
level === 'warning') ? level : 'info';
|
||
|
||
// Single-toast policy: dismiss any existing toast immediately
|
||
// so the new one is always the most recent. Matches the
|
||
// classifier's prior behavior and avoids stack-of-toasts UX.
|
||
var existing = document.querySelector('.zddc-toast');
|
||
if (existing) existing.remove();
|
||
|
||
var el = document.createElement('div');
|
||
el.className = 'zddc-toast zddc-toast--' + lvl;
|
||
// ARIA: errors get assertive (interrupts SR queue), others polite.
|
||
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
|
||
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
|
||
el.textContent = message == null ? '' : String(message);
|
||
document.body.appendChild(el);
|
||
|
||
var dur = typeof opts.durationMs === 'number' ?
|
||
opts.durationMs : DEFAULT_DURATION_MS;
|
||
var timer = setTimeout(function () {
|
||
el.classList.add('zddc-toast--fade');
|
||
setTimeout(function () {
|
||
if (el.parentNode) el.parentNode.removeChild(el);
|
||
}, FADE_MS);
|
||
}, dur);
|
||
|
||
// Click-to-dismiss. Useful for sticky errors the user wants gone.
|
||
el.addEventListener('click', function () {
|
||
clearTimeout(timer);
|
||
if (el.parentNode) el.parentNode.removeChild(el);
|
||
});
|
||
|
||
return el;
|
||
}
|
||
|
||
window.zddc.toast = toast;
|
||
|
||
// Route window.alert() calls into the toast helper. Every tool has
|
||
// accumulated some `alert(...)` sites for error reporting; rather
|
||
// than touch each one, intercept globally so they're non-blocking
|
||
// and ARIA-announced consistently. Native alert is preserved on
|
||
// window.alertNative for the rare case where a truly modal block
|
||
// is needed (e.g. before navigating away with unsaved changes).
|
||
if (typeof window.alert === 'function' && !window.alertNative) {
|
||
window.alertNative = window.alert.bind(window);
|
||
window.alert = function (msg) {
|
||
toast(String(msg == null ? '' : msg), 'error');
|
||
};
|
||
}
|
||
})();
|
||
|
||
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
|
||
// every tool's header into a clickable link. The destination is the
|
||
// nearest "home" the user can sensibly back out to:
|
||
//
|
||
// file:// → no wrap (no server home)
|
||
// http(s)://host/ → wrap, href = /
|
||
// http(s)://host/<tool>.html (deployment root)→ wrap, href = /
|
||
// http(s)://host/<project>/... → wrap, href = /<project>
|
||
//
|
||
// When inside a project, the logo takes the user to the project
|
||
// landing (synthetic page with the four lifecycle-stage cards + MDL
|
||
// instructions). When at the deployment root, the logo points at /
|
||
// (the project picker). Offline, the logo stays decorative — there's
|
||
// no real "home" to go to.
|
||
//
|
||
// Mounts as a sibling-replacement on DOMContentLoaded: wraps the
|
||
// existing logo SVG in an <a>, preserving classes and attributes.
|
||
// Idempotent: re-mounting on an already-wrapped logo is a no-op.
|
||
//
|
||
// Tools that want to override (e.g. a deployment that pins logo to
|
||
// an external URL) can set window.zddc.logo.disabled = true before
|
||
// DOMContentLoaded and inject their own anchor.
|
||
(function () {
|
||
'use strict';
|
||
|
||
if (!window.zddc) window.zddc = {};
|
||
if (window.zddc.logo) return;
|
||
|
||
function projectSegment(pathname) {
|
||
var parts = pathname.split('/').filter(Boolean);
|
||
if (parts.length === 0) return null;
|
||
var first = parts[0];
|
||
// Tool HTMLs at the deployment root (index.html, archive.html
|
||
// with ?projects=...) don't carry a project segment.
|
||
if (first.indexOf('.') !== -1) return null;
|
||
return first;
|
||
}
|
||
|
||
function targetHref() {
|
||
if (typeof location === 'undefined') return null;
|
||
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
||
return null;
|
||
}
|
||
if (window.zddc.logo && window.zddc.logo.disabled) return null;
|
||
var seg = projectSegment(location.pathname);
|
||
return seg ? '/' + encodeURIComponent(seg) : '/';
|
||
}
|
||
|
||
function mount() {
|
||
var logo = document.querySelector('.app-header__logo');
|
||
if (!logo) return;
|
||
// Already wrapped (template-supplied anchor, or a previous mount).
|
||
if (logo.parentElement && logo.parentElement.tagName === 'A' &&
|
||
logo.parentElement.classList.contains('app-header__logo-link')) {
|
||
return;
|
||
}
|
||
var href = targetHref();
|
||
if (!href) return;
|
||
var a = document.createElement('a');
|
||
a.href = href;
|
||
a.className = 'app-header__logo-link';
|
||
var label = href === '/' ? 'ZDDC home' : 'Project home';
|
||
a.title = label;
|
||
a.setAttribute('aria-label', label);
|
||
logo.parentNode.insertBefore(a, logo);
|
||
a.appendChild(logo);
|
||
}
|
||
|
||
window.zddc.logo = {
|
||
mount: mount,
|
||
// Test seam.
|
||
_projectSegment: projectSegment,
|
||
_targetHref: targetHref,
|
||
disabled: false,
|
||
};
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
||
} else {
|
||
mount();
|
||
}
|
||
})();
|
||
|
||
/**
|
||
* 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();
|
||
}
|
||
}());
|
||
|
||
// shared/elevation.js — admin elevation via URL toggle.
|
||
//
|
||
// Sudo-style model: admins behave as normal users by default; elevating
|
||
// the session turns on admin escape hatches (WORM bypass, .zddc edit
|
||
// authority, profile admin scaffolds). State is carried in a
|
||
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
|
||
// → zddc.Principal{Elevated}.
|
||
//
|
||
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
|
||
// (or the red banner's "Drop admin" button) to drop — so it's reachable
|
||
// from ANY zddc-server page, not just ones that render a header control.
|
||
// The cookie is the sticky state: it persists across navigation for its
|
||
// Max-Age window, so the param need not stay in the URL (we strip it).
|
||
// Arming is gated on /.profile/access `can_elevate`, so only real admins
|
||
// can set it; a non-admin's ?admin=true is a silent no-op.
|
||
//
|
||
// Applying the cookie reloads to the cleaned URL so the server re-renders
|
||
// under the new state (admin scaffolds in some tool HTML are server-
|
||
// rendered, so a client-only flip wouldn't reach them). The red viewport
|
||
// border + banner (applyArmedChrome) reflect the cookie on every load.
|
||
(function () {
|
||
'use strict';
|
||
|
||
if (!window.zddc) window.zddc = {};
|
||
if (window.zddc.elevation) return;
|
||
|
||
var COOKIE_NAME = 'zddc-elevate';
|
||
|
||
function isElevated() {
|
||
var parts = document.cookie.split(';');
|
||
for (var i = 0; i < parts.length; i++) {
|
||
var kv = parts[i].trim().split('=');
|
||
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function setElevated(on) {
|
||
if (on) {
|
||
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
|
||
// shapes. Max-Age caps the elevation window so a forgotten
|
||
// tab doesn't leave admin powers active indefinitely (sudo's
|
||
// 5-minute precedent informs the number — 30 minutes is a
|
||
// reasonable trade between annoyance and exposure).
|
||
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
|
||
} else {
|
||
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
||
}
|
||
}
|
||
|
||
async function fetchAccess() {
|
||
try {
|
||
var resp = await fetch('/.profile/access', {
|
||
headers: { 'Accept': 'application/json' },
|
||
credentials: 'same-origin',
|
||
cache: 'no-cache'
|
||
});
|
||
if (!resp.ok) return null;
|
||
return await resp.json();
|
||
} catch (_e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── URL toggle: ?admin=true | ?admin=false (typeable anywhere) ──────
|
||
//
|
||
// Admin mode is toggled via a URL query param rather than an on-screen
|
||
// checkbox, so it's reachable from any zddc-server page. The param only
|
||
// SETS the cookie; the cookie is the sticky state (it persists across
|
||
// navigation for its Max-Age window and is what the server reads), so
|
||
// there's no need to keep ?admin= in the URL once applied.
|
||
|
||
// adminParam returns true/false for a recognised ?admin= value, or null
|
||
// when absent / unrecognised (ignored).
|
||
function adminParam() {
|
||
try {
|
||
var v = new URLSearchParams(window.location.search).get('admin');
|
||
if (v === null) return null;
|
||
v = v.toLowerCase();
|
||
if (v === 'true' || v === '1' || v === 'on' || v === 'yes') return true;
|
||
if (v === 'false' || v === '0' || v === 'off' || v === 'no') return false;
|
||
return null;
|
||
} catch (_e) { return null; }
|
||
}
|
||
|
||
// urlWithoutAdmin returns the current URL with the admin param stripped
|
||
// (other params + hash preserved) — what we navigate/replace to so the
|
||
// dirty param isn't bookmarked and Back doesn't re-trigger it.
|
||
function urlWithoutAdmin() {
|
||
var u = new URL(window.location.href);
|
||
u.searchParams.delete('admin');
|
||
var qs = u.searchParams.toString();
|
||
return u.pathname + (qs ? '?' + qs : '') + u.hash;
|
||
}
|
||
|
||
// handleAdminParam applies a ?admin= request. Returns true when a
|
||
// navigation (reload) is underway so the caller can stop. Enabling is
|
||
// gated on can_elevate — a non-admin who types ?admin=true just gets
|
||
// the param stripped, never a misleading red border. Disabling is open
|
||
// (anyone may drop a cookie they somehow hold).
|
||
async function handleAdminParam() {
|
||
var want = adminParam();
|
||
if (want === null) return false;
|
||
var clean = urlWithoutAdmin();
|
||
if (want === isElevated()) {
|
||
// Already in the requested state — just clean the URL, no reload.
|
||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||
return false;
|
||
}
|
||
if (want === true) {
|
||
var access = await fetchAccess();
|
||
if (!access || !access.can_elevate) {
|
||
try { history.replaceState(history.state, '', clean); } catch (_e) {}
|
||
return false;
|
||
}
|
||
setElevated(true);
|
||
} else {
|
||
setElevated(false);
|
||
}
|
||
// Navigate to the clean URL (a real load, so the server re-renders
|
||
// under the new cookie) and replace history so Back is safe.
|
||
window.location.replace(clean);
|
||
return true;
|
||
}
|
||
|
||
// Page-wide affordances when elevation is active. The toggle alone
|
||
// is easy to miss — admin mode silently bypasses WORM and ACL
|
||
// restrictions, which produces surprising "I shouldn't have been
|
||
// able to do that" moments. A body class + a sticky banner with a
|
||
// one-click disable make the armed state unmistakable.
|
||
function applyArmedChrome(elevated) {
|
||
var b = document.body;
|
||
if (!b) return;
|
||
if (elevated) b.classList.add('is-elevated');
|
||
else b.classList.remove('is-elevated');
|
||
|
||
var banner = document.getElementById('elevation-banner');
|
||
if (elevated) {
|
||
if (!banner) {
|
||
banner = document.createElement('div');
|
||
banner.id = 'elevation-banner';
|
||
banner.className = 'elevation-banner';
|
||
banner.setAttribute('role', 'alert');
|
||
banner.innerHTML =
|
||
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
|
||
+ '<span class="elevation-banner__msg">'
|
||
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
|
||
+ '</span>'
|
||
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
|
||
+ 'Drop admin'
|
||
+ '</button>';
|
||
document.body.insertBefore(banner, document.body.firstChild);
|
||
var off = banner.querySelector('#elevation-banner-off');
|
||
if (off) off.addEventListener('click', function () {
|
||
setElevated(false);
|
||
window.location.reload();
|
||
});
|
||
}
|
||
} else if (banner) {
|
||
banner.parentNode.removeChild(banner);
|
||
}
|
||
}
|
||
|
||
async function init() {
|
||
// Apply (or tear down) the red border + banner from the cookie on
|
||
// every page load — admin mode is toggled by URL, but the armed
|
||
// chrome must surface everywhere so the user can't accidentally
|
||
// write through an elevated context on a page they didn't toggle.
|
||
applyArmedChrome(isElevated());
|
||
|
||
// Honour ?admin=true|false typed into any zddc-server URL. There's
|
||
// no on-screen toggle anymore — the URL is the enable path and the
|
||
// red banner's "Drop admin" button is the one-click disable.
|
||
await handleAdminParam();
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
|
||
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||
})();
|
||
|
||
// shared/cap.js — client-side capability helpers for permission gating.
|
||
//
|
||
// Three small helpers, exposed under window.zddc.cap, that wrap the
|
||
// server's verbs / /.profile/access?path / 403 missing_verb surface:
|
||
//
|
||
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
|
||
// /.profile/access?path=<urlpath> and
|
||
// memoises per-path for the session.
|
||
// Used by tools to gate top-of-page
|
||
// affordances (Publish, +Add row,
|
||
// +New folder) on PathVerbs.
|
||
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
|
||
// "rwcda"-subset) for the listed verb.
|
||
// Transition: falls back to
|
||
// node.writable for 'w' when verbs
|
||
// is absent, so the legacy field still
|
||
// drives gating on old listings.
|
||
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
|
||
// parses the JSON body for
|
||
// missing_verb and renders a toast.
|
||
// Offers "Elevate" when the path's
|
||
// /.profile/access?path= reports a
|
||
// path_can_elevate_grant covering the
|
||
// missing verb.
|
||
//
|
||
// Tools using this module must concat shared/cap.js AFTER shared/
|
||
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
|
||
(function () {
|
||
'use strict';
|
||
|
||
if (!window.zddc) window.zddc = {};
|
||
if (window.zddc.cap) return;
|
||
|
||
var pathCache = new Map(); // path → AccessView (or null sentinel)
|
||
|
||
async function fetchAccess(path) {
|
||
// file:// pages have no server to fetch /.profile/access from;
|
||
// calling fetch() there logs a browser-level error before our
|
||
// catch even runs. Short-circuit so offline tools (browse on
|
||
// a picked folder, form opened from a file URL) silently
|
||
// degrade to "no path-scoped info, fall back to existing
|
||
// gating signals".
|
||
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
||
return null;
|
||
}
|
||
try {
|
||
var url = '/.profile/access';
|
||
if (path) url += '?path=' + encodeURIComponent(path);
|
||
var resp = await fetch(url, {
|
||
headers: { 'Accept': 'application/json' },
|
||
credentials: 'same-origin',
|
||
cache: 'no-cache'
|
||
});
|
||
if (!resp.ok) return null;
|
||
return await resp.json();
|
||
} catch (_e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// at(path) — fetch path-scoped access view, memoised per path
|
||
// within the page session. Cache is page-scoped: any elevation
|
||
// toggle forces a hard reload (see shared/elevation.js), which
|
||
// resets the cache so stale-after-elevation isn't a concern. Pass
|
||
// null/undefined for the global view (no ?path=).
|
||
async function at(path) {
|
||
var key = path || '';
|
||
if (pathCache.has(key)) return pathCache.get(key);
|
||
var view = await fetchAccess(path);
|
||
pathCache.set(key, view);
|
||
return view;
|
||
}
|
||
|
||
// has(node, verb) — check a per-entry verbs string for a single
|
||
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
|
||
// Transition shim: when node.verbs is absent, fall back to
|
||
// node.writable for 'w' so the legacy field keeps editor save
|
||
// buttons working on old listings — drop this fallback once every
|
||
// tool's loader sets node.verbs unconditionally.
|
||
function has(node, verb) {
|
||
if (!node) return false;
|
||
if (typeof node.verbs === 'string') {
|
||
return node.verbs.indexOf(verb) !== -1;
|
||
}
|
||
if (verb === 'w' && typeof node.writable === 'boolean') {
|
||
return node.writable;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
|
||
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
|
||
var VERB_LABELS = {
|
||
r: 'read',
|
||
w: 'write',
|
||
c: 'create',
|
||
d: 'delete',
|
||
a: 'edit access rules'
|
||
};
|
||
|
||
// handleForbidden(resp, opts) — render a 403 toast naming the
|
||
// missing verb. opts.path (optional) is the URL the failed request
|
||
// hit; when provided, the helper consults /.profile/access?path= to
|
||
// decide whether to offer an Elevate action. opts.context is an
|
||
// optional string prefix shown before the verb message ("Save",
|
||
// "Delete", etc.) — purely cosmetic.
|
||
//
|
||
// Best-effort: when the body isn't JSON or missing_verb is
|
||
// absent, falls back to a plain "Forbidden" toast. Returns the
|
||
// Promise so callers can await before chaining.
|
||
async function handleForbidden(resp, opts) {
|
||
opts = opts || {};
|
||
var missing = '';
|
||
try {
|
||
var body = await resp.clone().json();
|
||
if (body && typeof body.missing_verb === 'string') {
|
||
missing = body.missing_verb;
|
||
}
|
||
} catch (_e) { /* non-JSON body */ }
|
||
|
||
var prefix = opts.context ? (opts.context + ': ') : '';
|
||
var verbLabel = VERB_LABELS[missing] || missing || '';
|
||
var msg;
|
||
if (verbLabel) {
|
||
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
|
||
} else {
|
||
msg = prefix + 'Forbidden.';
|
||
}
|
||
|
||
// Optional elevate offer: only when the caller supplied a
|
||
// path AND the path-scoped access view reports an elevation
|
||
// grant covering the missing verb. Render as a clickable
|
||
// action appended to the toast message; clicking sets the
|
||
// elevation cookie and reloads, matching the header toggle.
|
||
var canOffer = false;
|
||
if (opts.path && missing) {
|
||
var view = await at(opts.path);
|
||
if (view && typeof view.path_can_elevate_grant === 'string'
|
||
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
|
||
canOffer = true;
|
||
}
|
||
}
|
||
|
||
var toastFn = (window.zddc && window.zddc.toast) || function () {};
|
||
var el = toastFn(msg, 'error', { durationMs: 8000 });
|
||
if (canOffer && el && el.appendChild) {
|
||
var btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'zddc-toast__action';
|
||
btn.textContent = 'Elevate';
|
||
btn.addEventListener('click', function (ev) {
|
||
ev.stopPropagation(); // don't dismiss the toast
|
||
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
|
||
window.zddc.elevation.setElevated(true);
|
||
window.location.reload();
|
||
}
|
||
});
|
||
el.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
||
})();
|
||
|
||
// shared/context-menu.js — generic context-menu framework exposed on
|
||
// window.zddc.menu. Built so every ZDDC tool can drop a right-click
|
||
// menu (or any programmatically-opened menu) onto its UI without
|
||
// shipping its own implementation.
|
||
//
|
||
// API:
|
||
// window.zddc.menu.open({ x, y, items, context })
|
||
// window.zddc.menu.close()
|
||
//
|
||
// `items` is an array (or a function returning an array, evaluated
|
||
// against `context` at open-time). Each entry is one of:
|
||
// { label, action, icon?, accel?, disabled?, visible?, danger?, tooltip? }
|
||
// — a normal menu item; `action(ctx)` fires on click/Enter.
|
||
// `tooltip` (string or fn(ctx)) sets the row's title attribute —
|
||
// useful for explaining WHY a disabled item is unavailable
|
||
// ("You don't have write access here", etc.).
|
||
// { label, checked, action, ... }
|
||
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
|
||
// a ✓ in the gutter when truthy.
|
||
// { label, items, ... }
|
||
// — submenu; `items` may itself be an array or fn(ctx).
|
||
// { separator: true }
|
||
// — horizontal divider. Leading/trailing/duplicate separators
|
||
// are collapsed automatically so callers can build items
|
||
// conditionally without managing dividers.
|
||
//
|
||
// Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and
|
||
// `items` may be a function — each is invoked with the context object
|
||
// so callers can render fully context-aware menus from a single
|
||
// declarative config.
|
||
//
|
||
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
|
||
// submenu, ArrowLeft / Escape backs up one level (or closes if
|
||
// already at the root), Enter / Space activates. Click-outside,
|
||
// window blur, scroll, and resize all dismiss.
|
||
(function () {
|
||
'use strict';
|
||
|
||
if (!window.zddc) window.zddc = {};
|
||
if (window.zddc.menu) return;
|
||
|
||
var SUBMENU_HOVER_MS = 180;
|
||
|
||
// Open menu stack — index 0 is the root, deeper entries are
|
||
// nested submenus. Each frame: { el, depth, parentRow? }.
|
||
var stack = [];
|
||
var rootContext = null;
|
||
var submenuTimer = null;
|
||
|
||
function resolve(val, ctx) {
|
||
return typeof val === 'function' ? val(ctx) : val;
|
||
}
|
||
|
||
function close() {
|
||
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
|
||
for (var i = 0; i < stack.length; i++) {
|
||
var fr = stack[i];
|
||
if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
|
||
}
|
||
stack = [];
|
||
rootContext = null;
|
||
document.removeEventListener('mousedown', onDocMouseDown, true);
|
||
document.removeEventListener('keydown', onDocKeyDown, true);
|
||
// blur is bound WITHOUT capture so we only react to the window
|
||
// itself losing focus — capturing would also fire when any
|
||
// inner element blurs (which happens every time the user moves
|
||
// the mouse between menu rows, since hover focuses the row).
|
||
window.removeEventListener('blur', close);
|
||
window.removeEventListener('resize', close, true);
|
||
window.removeEventListener('scroll', onDocScroll, true);
|
||
}
|
||
|
||
function open(opts) {
|
||
opts = opts || {};
|
||
close();
|
||
rootContext = opts.context || {};
|
||
var items = resolve(opts.items, rootContext) || [];
|
||
var el = buildMenu(items, rootContext, 0);
|
||
document.body.appendChild(el);
|
||
position(el, opts.x || 0, opts.y || 0, null);
|
||
stack.push({ el: el, depth: 0 });
|
||
|
||
document.addEventListener('mousedown', onDocMouseDown, true);
|
||
document.addEventListener('keydown', onDocKeyDown, true);
|
||
window.addEventListener('blur', close);
|
||
window.addEventListener('resize', close, true);
|
||
window.addEventListener('scroll', onDocScroll, true);
|
||
|
||
focusFirst(el);
|
||
}
|
||
|
||
// ── Building ─────────────────────────────────────────────────────────
|
||
|
||
function collapseSeparators(items) {
|
||
var out = [];
|
||
for (var i = 0; i < items.length; i++) {
|
||
var it = items[i];
|
||
if (it && it.separator) {
|
||
if (out.length === 0) continue;
|
||
if (out[out.length - 1].separator) continue;
|
||
out.push(it);
|
||
} else if (it) {
|
||
out.push(it);
|
||
}
|
||
}
|
||
while (out.length && out[out.length - 1].separator) out.pop();
|
||
return out;
|
||
}
|
||
|
||
function buildMenu(items, ctx, depth) {
|
||
var menu = document.createElement('div');
|
||
menu.className = 'zddc-menu';
|
||
menu.setAttribute('role', 'menu');
|
||
menu.dataset.depth = String(depth);
|
||
// Suppress the native context menu over our own menu.
|
||
menu.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||
|
||
var filtered = items.filter(function (it) {
|
||
if (!it) return false;
|
||
if (it.separator) return true;
|
||
if ('visible' in it && !resolve(it.visible, ctx)) return false;
|
||
return true;
|
||
});
|
||
var pruned = collapseSeparators(filtered);
|
||
|
||
for (var i = 0; i < pruned.length; i++) {
|
||
menu.appendChild(buildRow(pruned[i], ctx, depth));
|
||
}
|
||
return menu;
|
||
}
|
||
|
||
function buildRow(item, ctx, depth) {
|
||
if (item.separator) {
|
||
var sep = document.createElement('div');
|
||
sep.className = 'zddc-menu__sep';
|
||
sep.setAttribute('role', 'separator');
|
||
return sep;
|
||
}
|
||
|
||
var hasSub = !!item.items;
|
||
var isToggle = ('checked' in item);
|
||
var disabled = 'disabled' in item ? !!resolve(item.disabled, ctx) : false;
|
||
|
||
var row = document.createElement('div');
|
||
row.className = 'zddc-menu__item';
|
||
if (item.danger) row.classList.add('zddc-menu__item--danger');
|
||
if (hasSub) row.classList.add('zddc-menu__item--has-sub');
|
||
if (disabled) {
|
||
row.classList.add('is-disabled');
|
||
row.setAttribute('aria-disabled', 'true');
|
||
}
|
||
if ('tooltip' in item) {
|
||
var tip = resolve(item.tooltip, ctx);
|
||
if (tip) row.title = String(tip);
|
||
}
|
||
row.setAttribute('role',
|
||
hasSub ? 'menuitem'
|
||
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
|
||
row.tabIndex = -1;
|
||
|
||
// Check gutter — present on every row so columns align.
|
||
var check = document.createElement('span');
|
||
check.className = 'zddc-menu__check';
|
||
if (isToggle) {
|
||
var on = !!resolve(item.checked, ctx);
|
||
if (on) {
|
||
check.textContent = '✓';
|
||
row.classList.add('is-checked');
|
||
row.setAttribute('aria-checked', 'true');
|
||
} else {
|
||
row.setAttribute('aria-checked', 'false');
|
||
}
|
||
}
|
||
row.appendChild(check);
|
||
|
||
// Icon column.
|
||
var icon = document.createElement('span');
|
||
icon.className = 'zddc-menu__icon';
|
||
if (item.icon) icon.textContent = item.icon;
|
||
row.appendChild(icon);
|
||
|
||
// Label.
|
||
var label = document.createElement('span');
|
||
label.className = 'zddc-menu__label';
|
||
label.textContent = String(resolve(item.label, ctx) || '');
|
||
row.appendChild(label);
|
||
|
||
// Accelerator hint (visual only; no binding).
|
||
var accel = document.createElement('span');
|
||
accel.className = 'zddc-menu__accel';
|
||
if (item.accel) accel.textContent = item.accel;
|
||
row.appendChild(accel);
|
||
|
||
// Submenu arrow.
|
||
var arrow = document.createElement('span');
|
||
arrow.className = 'zddc-menu__arrow';
|
||
if (hasSub) arrow.textContent = '▸';
|
||
row.appendChild(arrow);
|
||
|
||
if (!disabled) {
|
||
row.addEventListener('mouseenter', function () {
|
||
// Hovering any row in a menu collapses deeper menus
|
||
// (so traversing siblings closes a previously-opened
|
||
// submenu) and re-focuses this row for keyboard nav.
|
||
closeBelow(depth);
|
||
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
|
||
if (hasSub) {
|
||
submenuTimer = setTimeout(function () {
|
||
openSubmenu(row, item, ctx, depth + 1, false);
|
||
}, SUBMENU_HOVER_MS);
|
||
}
|
||
try { row.focus({ preventScroll: true }); } catch (_e) { row.focus(); }
|
||
});
|
||
row.addEventListener('click', function (e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
|
||
if (hasSub) {
|
||
openSubmenu(row, item, ctx, depth + 1, true);
|
||
return;
|
||
}
|
||
activate(item, ctx);
|
||
});
|
||
}
|
||
return row;
|
||
}
|
||
|
||
function activate(item, ctx) {
|
||
try {
|
||
if (typeof item.action === 'function') item.action(ctx);
|
||
} finally {
|
||
close();
|
||
}
|
||
}
|
||
|
||
function openSubmenu(parentRow, parentItem, ctx, depth, takeFocus) {
|
||
closeBelow(depth - 1);
|
||
var items = resolve(parentItem.items, ctx) || [];
|
||
var el = buildMenu(items, ctx, depth);
|
||
document.body.appendChild(el);
|
||
var rect = parentRow.getBoundingClientRect();
|
||
// Slight overlap so pointer-cross feels continuous.
|
||
position(el, rect.right - 2, rect.top - 4, parentRow);
|
||
stack.push({ el: el, depth: depth, parentRow: parentRow });
|
||
if (takeFocus) focusFirst(el);
|
||
}
|
||
|
||
function closeBelow(depth) {
|
||
while (stack.length && stack[stack.length - 1].depth > depth) {
|
||
var fr = stack.pop();
|
||
if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
|
||
}
|
||
}
|
||
|
||
// ── Positioning ──────────────────────────────────────────────────────
|
||
|
||
function position(el, x, y, parentRow) {
|
||
// Fixed so we ignore document scroll; measure after layout.
|
||
el.style.position = 'fixed';
|
||
el.style.left = '0px';
|
||
el.style.top = '0px';
|
||
el.style.visibility = 'hidden';
|
||
var rect = el.getBoundingClientRect();
|
||
var w = rect.width;
|
||
var h = rect.height;
|
||
var vw = window.innerWidth;
|
||
var vh = window.innerHeight;
|
||
|
||
var leftX = x;
|
||
if (leftX + w > vw - 4) {
|
||
if (parentRow) {
|
||
var pr = parentRow.getBoundingClientRect();
|
||
leftX = pr.left - w + 2; // flip submenu to the left
|
||
} else {
|
||
leftX = Math.max(4, x - w); // flip root menu left of cursor
|
||
}
|
||
}
|
||
if (leftX < 4) leftX = 4;
|
||
|
||
var topY = y;
|
||
if (topY + h > vh - 4) topY = Math.max(4, vh - h - 4);
|
||
if (topY < 4) topY = 4;
|
||
|
||
el.style.left = leftX + 'px';
|
||
el.style.top = topY + 'px';
|
||
el.style.visibility = '';
|
||
}
|
||
|
||
// ── Focus + keyboard ─────────────────────────────────────────────────
|
||
|
||
function focusable(menuEl) {
|
||
return Array.prototype.slice.call(
|
||
menuEl.querySelectorAll('.zddc-menu__item:not(.is-disabled)'));
|
||
}
|
||
|
||
function focusFirst(menuEl) {
|
||
var items = focusable(menuEl);
|
||
if (items.length) {
|
||
try { items[0].focus({ preventScroll: true }); }
|
||
catch (_e) { items[0].focus(); }
|
||
}
|
||
}
|
||
|
||
function onDocMouseDown(e) {
|
||
for (var i = 0; i < stack.length; i++) {
|
||
if (stack[i].el.contains(e.target)) return;
|
||
}
|
||
close();
|
||
}
|
||
|
||
// Scroll listener uses capture so scrolls inside any element (the
|
||
// tree pane, the document, etc.) dismiss the menu — its position
|
||
// is fixed and would otherwise hang over stale content. Scrolls
|
||
// that originate inside the menu itself (a future tall submenu)
|
||
// are ignored.
|
||
function onDocScroll(e) {
|
||
var t = e.target;
|
||
for (var i = 0; i < stack.length; i++) {
|
||
if (stack[i].el === t || (t && t.nodeType === 1 && stack[i].el.contains(t))) {
|
||
return;
|
||
}
|
||
}
|
||
close();
|
||
}
|
||
|
||
function onDocKeyDown(e) {
|
||
if (!stack.length) return;
|
||
var top = stack[stack.length - 1];
|
||
var items = focusable(top.el);
|
||
var active = document.activeElement;
|
||
var idx = items.indexOf(active);
|
||
|
||
switch (e.key) {
|
||
case 'Escape':
|
||
e.preventDefault();
|
||
if (stack.length > 1) {
|
||
var fr = stack.pop();
|
||
if (fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
|
||
if (fr.parentRow) fr.parentRow.focus();
|
||
} else {
|
||
close();
|
||
}
|
||
return;
|
||
case 'ArrowDown':
|
||
e.preventDefault();
|
||
if (!items.length) return;
|
||
items[idx < 0 ? 0 : (idx + 1) % items.length].focus();
|
||
return;
|
||
case 'ArrowUp':
|
||
e.preventDefault();
|
||
if (!items.length) return;
|
||
items[idx < 0 ? items.length - 1
|
||
: (idx - 1 + items.length) % items.length].focus();
|
||
return;
|
||
case 'Home':
|
||
e.preventDefault();
|
||
if (items.length) items[0].focus();
|
||
return;
|
||
case 'End':
|
||
e.preventDefault();
|
||
if (items.length) items[items.length - 1].focus();
|
||
return;
|
||
case 'ArrowRight':
|
||
if (active && active.classList.contains('zddc-menu__item--has-sub')) {
|
||
e.preventDefault();
|
||
active.click();
|
||
}
|
||
return;
|
||
case 'ArrowLeft':
|
||
if (stack.length > 1) {
|
||
e.preventDefault();
|
||
var fr2 = stack.pop();
|
||
if (fr2.el.parentNode) fr2.el.parentNode.removeChild(fr2.el);
|
||
if (fr2.parentRow) fr2.parentRow.focus();
|
||
}
|
||
return;
|
||
case 'Enter':
|
||
case ' ':
|
||
if (active) {
|
||
e.preventDefault();
|
||
active.click();
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
window.zddc.menu = { open: open, close: close };
|
||
})();
|
||
|
||
// 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).
|
||
// range: {anchor: {row, col}, focus: {row, col}} | null
|
||
// — multi-cell range selection (Phase 5).
|
||
selected: null,
|
||
editing: false,
|
||
range: null,
|
||
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[]');
|
||
}
|
||
|
||
// Optional row schema from <dir>/form.yaml — same JSON Schema
|
||
// the form-mode renderer uses. Phase 2 derives per-cell editor
|
||
// widgets from it (text/number/date/select/checkbox).
|
||
// Best-effort: a directory with only table.yaml still renders
|
||
// as a sortable/filterable table; cells fall back to plain
|
||
// text inputs without per-property hints.
|
||
let rowSchema = null;
|
||
try {
|
||
const formSpec = await readYaml(dir, 'form.yaml');
|
||
if (formSpec && formSpec.schema) {
|
||
rowSchema = formSpec.schema;
|
||
}
|
||
} catch (_) {
|
||
// form.yaml missing or unreadable; carry on without it.
|
||
}
|
||
|
||
// 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,
|
||
// addable defaults to true; tables can opt out with
|
||
// `addable: false` (used by project-rollup MDL/RSK where the
|
||
// party affiliation of a new row is ambiguous — add at the
|
||
// per-party path instead).
|
||
addable: spec.addable !== false,
|
||
rowSchema: rowSchema,
|
||
rows: rows
|
||
};
|
||
}
|
||
|
||
function tableNameFromUrl(pathname) {
|
||
// Two URL shapes resolve to a table page:
|
||
// Form A — /<…>/<rowsdir>/table.html (legacy/explicit
|
||
// entry-point; the tool was opened via the
|
||
// literal file URL).
|
||
// Form B — /<…>/<rowsdir> or /<…>/<rowsdir>/ (served
|
||
// by the cascade's `default_tool: tables` at
|
||
// archive/<party>/mdl; the URL is the directory
|
||
// itself, no trailing filename).
|
||
// In both cases the table name is the rows-directory basename.
|
||
const a = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
|
||
if (a) return a[1];
|
||
const trimmed = String(pathname || '').replace(/\/$/, '');
|
||
const b = trimmed.match(/\/([^\/]+)$/);
|
||
return b ? b[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 handle = await rowsDir.getFileHandle(entry.name);
|
||
const file = await handle.getFile();
|
||
const data = window.jsyaml.load(await file.text());
|
||
rows.push({
|
||
url: rowEditUrl(entry.name),
|
||
// Underlying YAML URL — strip the trailing .html
|
||
// from the form-mode re-edit URL. Phase 3 PUTs to
|
||
// this URL with If-Match: <etag> for optimistic
|
||
// concurrency.
|
||
yamlUrl: rowEditUrl(entry.name).replace(/\.html$/, ''),
|
||
data: data || {},
|
||
// ETag captured by HttpFileHandle.getFile from the
|
||
// server's response header. null in offline / file://
|
||
// mode (no HTTP roundtrip happened).
|
||
etag: handle._etag || null,
|
||
editable: true
|
||
});
|
||
} catch (err) {
|
||
console.warn('[tables] skipping unparseable row', entry.name, err);
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
// Re-edit URL for one row. The page directory is the same
|
||
// directory the rows live in, regardless of which URL shape
|
||
// (Form A `…/table.html` vs Form B bare `…/<rowsdir>`) we were
|
||
// opened with — see tableNameFromUrl.
|
||
function rowEditUrl(rowFileName) {
|
||
let pageDir = location.pathname.replace(/\/table\.html$/, '/');
|
||
if (!pageDir.endsWith('/')) pageDir += '/';
|
||
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);
|
||
|
||
// editor.js — Phase 1 of editable-cell mode.
|
||
//
|
||
// Owns the cell-selection + per-cell edit lifecycle. Implements the
|
||
// W3C ARIA grid-pattern keyboard semantics:
|
||
//
|
||
// - Arrow keys move the selected cell.
|
||
// - Tab / Shift-Tab move right / left, wrapping to next / prev row.
|
||
// - Enter, F2, double-click, or any printable character enter edit
|
||
// mode (Enter and F2 keep the existing value; printable chars
|
||
// replace it; double-click opens with the existing value).
|
||
// - In edit mode: Enter commits and moves down, Tab commits and
|
||
// moves right, Escape cancels (restoring the prior value), blur
|
||
// commits.
|
||
//
|
||
// Roving tabindex: only the selected cell carries tabindex=0; all
|
||
// others are tabindex=-1. This makes the grid a single tab-stop in
|
||
// the page's tab order, which is the documented spreadsheet UX.
|
||
//
|
||
// Edits in this phase live in app.state.drafts and never hit the
|
||
// network — Phase 3 wires the row-blur PUT.
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
// --- Helpers ------------------------------------------------------
|
||
|
||
function tableEl() { return document.getElementById('table-root'); }
|
||
function cellAt(r, c) { return cellsByRowCol(r, c); }
|
||
|
||
// The displayed table is filtered+sorted; selection is keyed by
|
||
// VISIBLE row index, not row id, so arrow keys behave intuitively
|
||
// even after sort / filter changes (the cell at row 3 column 2
|
||
// stays at row 3 column 2 even if the underlying row id moved).
|
||
// This is how Excel and Google Sheets behave too.
|
||
function cellsByRowCol(r, c) {
|
||
const t = tableEl();
|
||
if (!t) return null;
|
||
const tbody = t.querySelector('tbody');
|
||
if (!tbody) return null;
|
||
const tr = tbody.children[r];
|
||
if (!tr) return null;
|
||
return tr.querySelector('[role="gridcell"][data-col-idx="' + c + '"]');
|
||
}
|
||
|
||
function isPrintableKey(ev) {
|
||
// A "printable" key produces a single character of text — e.g.
|
||
// 'a', '7', '$'. Function keys, arrows, modifiers etc. either
|
||
// have multi-char `key` values ('ArrowDown') or are non-text.
|
||
// ev.ctrlKey / metaKey suppress so Cmd-A et al. don't trigger
|
||
// edit mode.
|
||
if (ev.key.length !== 1) return false;
|
||
if (ev.ctrlKey || ev.metaKey || ev.altKey) return false;
|
||
return true;
|
||
}
|
||
|
||
function rowCount() {
|
||
const t = tableEl();
|
||
if (!t) return 0;
|
||
return t.querySelectorAll('tbody > tr').length;
|
||
}
|
||
|
||
function colCount() {
|
||
const cols = (app.context && app.context.columns) || [];
|
||
return Array.isArray(cols) ? cols.length : 0;
|
||
}
|
||
|
||
function colAt(c) {
|
||
const cols = (app.context && app.context.columns) || [];
|
||
return cols[c] || null;
|
||
}
|
||
|
||
function rowDataAt(r) {
|
||
// The visible row at index r. Walk the rendered tbody to find
|
||
// its data-row-id, then look up the row in app.state.rows.
|
||
// app.state.rows holds the SORTED+FILTERED current view (kept
|
||
// in sync by main.js paint()).
|
||
const t = tableEl();
|
||
if (!t) return null;
|
||
const tr = t.querySelectorAll('tbody > tr')[r];
|
||
if (!tr) return null;
|
||
const rowId = tr.getAttribute('data-row-id');
|
||
if (rowId == null) return null;
|
||
const all = app.state.rows || [];
|
||
for (let i = 0; i < all.length; i++) {
|
||
if (rowKey(all[i]) === rowId) {
|
||
return all[i];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function rowKey(row) {
|
||
// Stable per-row identity. Each context row has a `url` (the
|
||
// <id>.yaml.html re-edit URL); the file basename inside that
|
||
// URL is unique per directory and survives sort/filter.
|
||
if (!row || !row.url) return '';
|
||
return row.url;
|
||
}
|
||
|
||
// --- Draft buffer -------------------------------------------------
|
||
|
||
function getDraft(rowId, field) {
|
||
const r = app.state.drafts[rowId];
|
||
if (!r) return undefined;
|
||
return r[field];
|
||
}
|
||
|
||
function setDraft(rowId, field, value) {
|
||
if (!app.state.drafts[rowId]) {
|
||
app.state.drafts[rowId] = {};
|
||
}
|
||
app.state.drafts[rowId][field] = value;
|
||
notifyDraftsChanged();
|
||
}
|
||
|
||
function clearDraftField(rowId, field) {
|
||
const r = app.state.drafts[rowId];
|
||
if (!r) return;
|
||
delete r[field];
|
||
if (Object.keys(r).length === 0) {
|
||
delete app.state.drafts[rowId];
|
||
}
|
||
notifyDraftsChanged();
|
||
}
|
||
|
||
// Notify the save module that drafts changed so it can update the
|
||
// toolbar Save button + count. Save module is optional in test
|
||
// fixtures, so the call is guarded.
|
||
function notifyDraftsChanged() {
|
||
const save = app.modules.save;
|
||
if (save && typeof save.onDraftsChanged === 'function') {
|
||
save.onDraftsChanged();
|
||
}
|
||
}
|
||
|
||
function effectiveCellValue(row, col) {
|
||
// Display draft value if present; otherwise the row's stored
|
||
// value. Used by render to keep the visible cell content in
|
||
// sync with uncommitted edits.
|
||
const drafted = getDraft(rowKey(row), col.field);
|
||
if (drafted !== undefined) {
|
||
return drafted;
|
||
}
|
||
return app.modules.util.resolveField(row.data, col.field);
|
||
}
|
||
|
||
// --- Selection (roving tabindex) ----------------------------------
|
||
|
||
function setSelected(r, c, opts) {
|
||
opts = opts || {};
|
||
const total = rowCount();
|
||
const cols = colCount();
|
||
if (total === 0 || cols === 0) {
|
||
app.state.selected = null;
|
||
notifySelectionChanged();
|
||
return;
|
||
}
|
||
if (r < 0) r = 0;
|
||
if (r > total - 1) r = total - 1;
|
||
if (c < 0) c = 0;
|
||
if (c > cols - 1) c = cols - 1;
|
||
|
||
const t = tableEl();
|
||
if (t) {
|
||
const all = t.querySelectorAll('[role="gridcell"]');
|
||
for (let i = 0; i < all.length; i++) {
|
||
all[i].setAttribute('tabindex', '-1');
|
||
all[i].classList.remove('zddc-table__cell--selected');
|
||
}
|
||
}
|
||
const target = cellAt(r, c);
|
||
if (target) {
|
||
target.setAttribute('tabindex', '0');
|
||
target.classList.add('zddc-table__cell--selected');
|
||
if (!opts.noFocus) {
|
||
target.focus({ preventScroll: false });
|
||
}
|
||
}
|
||
app.state.selected = { row: r, col: c };
|
||
// Plain selection moves clear the multi-cell range. Range
|
||
// operations (Shift+click, Shift+arrow) pass keepRange so the
|
||
// anchor stays put while the focus cell moves.
|
||
if (!opts.keepRange) {
|
||
clearRange();
|
||
}
|
||
notifySelectionChanged();
|
||
}
|
||
|
||
function notifySelectionChanged() {
|
||
// Phase 3 wires the row-blur save trigger here. save module is
|
||
// optional in test fixtures that don't include it.
|
||
const save = app.modules.save;
|
||
if (save && typeof save.onSelectionChanged === 'function') {
|
||
save.onSelectionChanged(app.state.selected);
|
||
}
|
||
}
|
||
|
||
function clearSelection() {
|
||
const t = tableEl();
|
||
if (t) {
|
||
const all = t.querySelectorAll('[role="gridcell"]');
|
||
for (let i = 0; i < all.length; i++) {
|
||
all[i].setAttribute('tabindex', '-1');
|
||
all[i].classList.remove('zddc-table__cell--selected');
|
||
}
|
||
}
|
||
app.state.selected = null;
|
||
}
|
||
|
||
// --- Edit mode ----------------------------------------------------
|
||
|
||
function enterEdit(initial) {
|
||
if (!app.state.selected) return;
|
||
if (app.state.editing) return;
|
||
const { row: r, col: c } = app.state.selected;
|
||
const cell = cellAt(r, c);
|
||
if (!cell) return;
|
||
const row = rowDataAt(r);
|
||
const col = colAt(c);
|
||
if (!row || !col) return;
|
||
|
||
// $-prefixed columns are system-synthesized fields (e.g. the
|
||
// `$party` source-party qualifier on project-rollup MDL/RSK
|
||
// views). Their value is derived from the row's canonical
|
||
// path on read and stripped before any write — editing them
|
||
// would have no effect on disk, so suppress entry to edit
|
||
// mode entirely. Selection still works for keyboard
|
||
// navigation across the cell.
|
||
if (typeof col.field === 'string' && col.field.charAt(0) === '$') {
|
||
return;
|
||
}
|
||
|
||
const propSchema = propertySchemaFor(col);
|
||
|
||
// Read-only cells (schema readOnly:true — e.g. the folder-bound
|
||
// originator the server derives from the party folder, or
|
||
// server-managed audit fields) can't be edited: any value the
|
||
// user typed would be overwritten on write. Suppress edit entry
|
||
// entirely; selection still works for keyboard navigation, same
|
||
// as the $-prefixed synthesized columns above.
|
||
if (propSchema && propSchema.readOnly) {
|
||
return;
|
||
}
|
||
|
||
// Complex-type cells (nested object, generic array, oneOf)
|
||
// can't be inline-edited cleanly — punt to the row's form
|
||
// editor in a side panel / new page. Phase 2 ships the
|
||
// navigation; Phase 5 may add a side-panel mount.
|
||
if (isComplexSchema(propSchema)) {
|
||
navigateToRowForm(row);
|
||
return;
|
||
}
|
||
|
||
const currentValue = effectiveCellValue(row, col);
|
||
const widget = makeWidget(propSchema, col, initial != null ? initial : currentValue);
|
||
const inputEl = widget.element;
|
||
inputEl.classList.add('zddc-table__cell-input');
|
||
inputEl.setAttribute('aria-label', 'Edit ' + (col.title || col.field));
|
||
|
||
// Replace the cell's text content with the editor widget.
|
||
// Stash the original text in dataset so cancel can restore it
|
||
// verbatim without re-running the formatCell logic.
|
||
cell.setAttribute('data-display', cell.textContent || '');
|
||
cell.textContent = '';
|
||
cell.appendChild(inputEl);
|
||
widget.focus();
|
||
|
||
app.state.editing = true;
|
||
|
||
function commit() {
|
||
if (!app.state.editing) return;
|
||
const newValue = widget.getValue();
|
||
const oldRaw = app.modules.util.resolveField(row.data, col.field);
|
||
// Compare by JSON-string equality so number 42 == "42"
|
||
// entered into a number input doesn't false-positive as
|
||
// a change. resolveField already returns the raw typed
|
||
// value from row.data.
|
||
if (sameValue(oldRaw, newValue)) {
|
||
clearDraftField(rowKey(row), col.field);
|
||
} else {
|
||
// Capture the prior draft value (or stored value if
|
||
// no draft) for undo. Lets Ctrl+Z restore intermediate
|
||
// state: e.g. typing A → B → C and undoing returns to
|
||
// B, not all the way back to the row's stored value.
|
||
const priorDraft = getDraft(rowKey(row), col.field);
|
||
const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw;
|
||
setDraft(rowKey(row), col.field, newValue);
|
||
const undoMod = app.modules.undo;
|
||
if (undoMod) {
|
||
undoMod.push({
|
||
cells: [{
|
||
rowId: rowKey(row),
|
||
field: col.field,
|
||
oldValue: undoOld,
|
||
newValue: newValue,
|
||
}],
|
||
});
|
||
}
|
||
}
|
||
tearDown(newValue);
|
||
}
|
||
|
||
function cancel() {
|
||
tearDown(null); // null = restore from data-display, no draft change
|
||
}
|
||
|
||
function tearDown(displayValue) {
|
||
inputEl.removeEventListener('keydown', onKey);
|
||
inputEl.removeEventListener('blur', onBlur);
|
||
const display = (displayValue !== undefined && displayValue !== null)
|
||
? renderableText(displayValue, col)
|
||
: (cell.getAttribute('data-display') || '');
|
||
cell.removeAttribute('data-display');
|
||
cell.textContent = display;
|
||
app.state.editing = false;
|
||
cell.focus({ preventScroll: false });
|
||
}
|
||
|
||
function onKey(ev) {
|
||
if (ev.key === 'Enter') {
|
||
ev.preventDefault();
|
||
ev.stopPropagation(); // don't let the table's onCellKey re-handle it
|
||
commit();
|
||
setSelected(r + 1, c);
|
||
} else if (ev.key === 'Escape') {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
cancel();
|
||
} else if (ev.key === 'Tab') {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
commit();
|
||
if (ev.shiftKey) {
|
||
moveSelection('left-wrap');
|
||
} else {
|
||
moveSelection('right-wrap');
|
||
}
|
||
}
|
||
// Other keys: stay in edit mode, let the input handle them.
|
||
}
|
||
|
||
function onBlur(_ev) {
|
||
// Blur (focus moved elsewhere). Commit any pending value.
|
||
// Schedule via setTimeout(0) so a programmatic refocus by
|
||
// tearDown→cell.focus doesn't re-fire blur during teardown.
|
||
if (app.state.editing) {
|
||
commit();
|
||
}
|
||
}
|
||
|
||
inputEl.addEventListener('keydown', onKey);
|
||
inputEl.addEventListener('blur', onBlur);
|
||
}
|
||
|
||
function renderableText(value, col) {
|
||
return app.modules.util.formatCell(value, col.format);
|
||
}
|
||
|
||
// --- Schema → editor widget factory --------------------------------
|
||
|
||
function propertySchemaFor(col) {
|
||
// Walk the row schema for this column's field. Returns null
|
||
// when no schema is present (best-effort: cells fall back to
|
||
// plain text editors). Supports a single dot-separated path
|
||
// — `properties.a.properties.b` for `field: "a.b"` — to mirror
|
||
// the existing util.resolveField conventions.
|
||
const ctx = app.context || {};
|
||
if (!ctx.rowSchema) return null;
|
||
const parts = String(col.field || '').split('.').filter(Boolean);
|
||
let s = ctx.rowSchema;
|
||
for (let i = 0; i < parts.length; i++) {
|
||
if (!s || !s.properties || !s.properties[parts[i]]) return null;
|
||
s = s.properties[parts[i]];
|
||
}
|
||
return s;
|
||
}
|
||
|
||
function isComplexSchema(s) {
|
||
if (!s) return false;
|
||
if (Array.isArray(s.oneOf) && s.oneOf.length > 0) return true;
|
||
if (Array.isArray(s.anyOf) && s.anyOf.length > 0) return true;
|
||
if (Array.isArray(s.allOf) && s.allOf.length > 0) return true;
|
||
if (s.type === 'object') return true;
|
||
if (s.type === 'array') {
|
||
// Multi-select-friendly arrays (string-enum + uniqueItems)
|
||
// get inline editing; everything else is complex.
|
||
const items = s.items || {};
|
||
const isMultiSelect = items.type === 'string'
|
||
&& Array.isArray(items.enum) && items.enum.length > 0
|
||
&& s.uniqueItems === true;
|
||
return !isMultiSelect;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function makeWidget(propSchema, col, initialValue) {
|
||
// Prefers explicit JSON Schema hints; falls back to column-spec
|
||
// hints (col.format / col.enum) for tables without a form.yaml;
|
||
// defaults to a plain text input.
|
||
const s = propSchema || {};
|
||
const colHint = col || {};
|
||
|
||
// Boolean → checkbox.
|
||
if (s.type === 'boolean') {
|
||
return widgetCheckbox(initialValue);
|
||
}
|
||
|
||
// Enum (string with explicit choices) → select dropdown.
|
||
const enumChoices = (Array.isArray(s.enum) && s.enum)
|
||
|| (Array.isArray(colHint.enum) && colHint.enum)
|
||
|| null;
|
||
if (enumChoices) {
|
||
return widgetSelect(enumChoices, initialValue);
|
||
}
|
||
|
||
// Multi-select (array of string-enum with uniqueItems).
|
||
if (s.type === 'array'
|
||
&& s.items && s.items.type === 'string'
|
||
&& Array.isArray(s.items.enum) && s.uniqueItems === true) {
|
||
return widgetMultiSelect(s.items.enum, initialValue);
|
||
}
|
||
|
||
// Number / integer → number input with min/max/step.
|
||
if (s.type === 'number' || s.type === 'integer'
|
||
|| colHint.format === 'number' || colHint.format === 'integer') {
|
||
return widgetNumber(s, initialValue);
|
||
}
|
||
|
||
// Date / date-time / email — typed inputs the browser can
|
||
// help validate.
|
||
const fmt = s.format || colHint.format;
|
||
if (fmt === 'date') return widgetTyped('date', initialValue);
|
||
if (fmt === 'date-time') return widgetTyped('datetime-local', initialValue);
|
||
if (fmt === 'email') return widgetTyped('email', initialValue);
|
||
|
||
// Long text → textarea (still inline; Phase 5 may add expand).
|
||
if (s.type === 'string' && Number(s.maxLength) > 200) {
|
||
return widgetTextarea(initialValue);
|
||
}
|
||
|
||
// Default: plain text input.
|
||
return widgetText(initialValue);
|
||
}
|
||
|
||
function widgetText(initial) {
|
||
const el = document.createElement('input');
|
||
el.type = 'text';
|
||
el.value = stringify(initial);
|
||
return {
|
||
element: el,
|
||
getValue: () => el.value,
|
||
focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} }
|
||
};
|
||
}
|
||
|
||
function widgetTextarea(initial) {
|
||
const el = document.createElement('textarea');
|
||
el.rows = 1;
|
||
el.value = stringify(initial);
|
||
return {
|
||
element: el,
|
||
getValue: () => el.value,
|
||
focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} }
|
||
};
|
||
}
|
||
|
||
function widgetTyped(htmlType, initial) {
|
||
const el = document.createElement('input');
|
||
el.type = htmlType;
|
||
el.value = stringify(initial);
|
||
return {
|
||
element: el,
|
||
getValue: () => el.value,
|
||
focus: () => el.focus()
|
||
};
|
||
}
|
||
|
||
function widgetNumber(s, initial) {
|
||
const el = document.createElement('input');
|
||
el.type = 'number';
|
||
if (s.minimum != null) el.min = String(s.minimum);
|
||
if (s.maximum != null) el.max = String(s.maximum);
|
||
if (s.type === 'integer') el.step = '1';
|
||
else if (s.multipleOf != null) el.step = String(s.multipleOf);
|
||
el.value = (initial == null || initial === '') ? '' : String(initial);
|
||
return {
|
||
element: el,
|
||
getValue: () => {
|
||
const v = el.value;
|
||
if (v === '') return null;
|
||
const n = Number(v);
|
||
return Number.isNaN(n) ? v : n;
|
||
},
|
||
focus: () => el.focus()
|
||
};
|
||
}
|
||
|
||
function widgetCheckbox(initial) {
|
||
const el = document.createElement('input');
|
||
el.type = 'checkbox';
|
||
el.checked = initial === true || initial === 'true';
|
||
return {
|
||
element: el,
|
||
getValue: () => el.checked,
|
||
focus: () => el.focus()
|
||
};
|
||
}
|
||
|
||
function widgetSelect(choices, initial) {
|
||
const el = document.createElement('select');
|
||
// Empty option lets the cell go back to "unset" without typing.
|
||
const empty = document.createElement('option');
|
||
empty.value = '';
|
||
empty.textContent = '—';
|
||
el.appendChild(empty);
|
||
for (let i = 0; i < choices.length; i++) {
|
||
const opt = document.createElement('option');
|
||
opt.value = String(choices[i]);
|
||
opt.textContent = String(choices[i]);
|
||
el.appendChild(opt);
|
||
}
|
||
el.value = initial == null ? '' : String(initial);
|
||
return {
|
||
element: el,
|
||
getValue: () => (el.value === '' ? null : el.value),
|
||
focus: () => el.focus()
|
||
};
|
||
}
|
||
|
||
function widgetMultiSelect(choices, initial) {
|
||
const el = document.createElement('select');
|
||
el.multiple = true;
|
||
el.size = Math.min(6, choices.length);
|
||
const initialSet = {};
|
||
const initArr = Array.isArray(initial) ? initial : [];
|
||
for (let i = 0; i < initArr.length; i++) initialSet[String(initArr[i])] = true;
|
||
for (let i = 0; i < choices.length; i++) {
|
||
const opt = document.createElement('option');
|
||
opt.value = String(choices[i]);
|
||
opt.textContent = String(choices[i]);
|
||
if (initialSet[opt.value]) opt.selected = true;
|
||
el.appendChild(opt);
|
||
}
|
||
return {
|
||
element: el,
|
||
getValue: () => {
|
||
const out = [];
|
||
for (let i = 0; i < el.options.length; i++) {
|
||
if (el.options[i].selected) out.push(el.options[i].value);
|
||
}
|
||
return out;
|
||
},
|
||
focus: () => el.focus()
|
||
};
|
||
}
|
||
|
||
function stringify(v) {
|
||
if (v == null) return '';
|
||
if (typeof v === 'object') {
|
||
try { return JSON.stringify(v); } catch (_) { return String(v); }
|
||
}
|
||
return String(v);
|
||
}
|
||
|
||
function sameValue(a, b) {
|
||
if (a === b) return true;
|
||
if (a == null && b == null) return true;
|
||
if (a == null || b == null) return false;
|
||
if (typeof a === 'object' || typeof b === 'object') {
|
||
try { return JSON.stringify(a) === JSON.stringify(b); }
|
||
catch (_) { return false; }
|
||
}
|
||
// Loose-string compare so number 42 == "42" from a text input.
|
||
return String(a) === String(b);
|
||
}
|
||
|
||
function navigateToRowForm(row) {
|
||
// Complex-type cells punt to the row's full form editor.
|
||
// The url field on each context row already points at
|
||
// <dir>/<id>.yaml.html — the form-mode re-edit URL.
|
||
if (!row || !row.url) return;
|
||
const nav = (window.tablesApp && window.tablesApp.navigateTo)
|
||
|| function (u) { window.location.assign(u); };
|
||
nav(row.url);
|
||
}
|
||
|
||
// --- Keyboard nav -------------------------------------------------
|
||
|
||
function moveSelection(dir) {
|
||
if (!app.state.selected) return;
|
||
let { row: r, col: c } = app.state.selected;
|
||
const total = rowCount();
|
||
const cols = colCount();
|
||
if (total === 0 || cols === 0) return;
|
||
|
||
switch (dir) {
|
||
case 'up': r = Math.max(0, r - 1); break;
|
||
case 'down': r = Math.min(total - 1, r + 1); break;
|
||
case 'left': c = Math.max(0, c - 1); break;
|
||
case 'right': c = Math.min(cols - 1, c + 1); break;
|
||
case 'home': c = 0; break;
|
||
case 'end': c = cols - 1; break;
|
||
case 'home-row': r = 0; c = 0; break;
|
||
case 'end-row': r = total - 1; c = cols - 1; break;
|
||
case 'left-wrap':
|
||
if (c > 0) { c--; }
|
||
else if (r > 0) { r--; c = cols - 1; }
|
||
break;
|
||
case 'right-wrap':
|
||
if (c < cols - 1) { c++; }
|
||
else if (r < total - 1) { r++; c = 0; }
|
||
break;
|
||
}
|
||
setSelected(r, c);
|
||
}
|
||
|
||
function onCellKey(ev) {
|
||
if (app.state.editing) return; // input owns its own keys
|
||
if (!app.state.selected) return;
|
||
const isRangeKey = ev.shiftKey;
|
||
|
||
switch (ev.key) {
|
||
case 'ArrowUp':
|
||
ev.preventDefault();
|
||
isRangeKey ? extendRange('up') : moveSelection('up');
|
||
return;
|
||
case 'ArrowDown':
|
||
ev.preventDefault();
|
||
isRangeKey ? extendRange('down') : moveSelection('down');
|
||
return;
|
||
case 'ArrowLeft':
|
||
ev.preventDefault();
|
||
isRangeKey ? extendRange('left') : moveSelection('left');
|
||
return;
|
||
case 'ArrowRight':
|
||
ev.preventDefault();
|
||
isRangeKey ? extendRange('right') : moveSelection('right');
|
||
return;
|
||
case 'Home':
|
||
ev.preventDefault();
|
||
if (ev.ctrlKey || ev.metaKey) moveSelection('home-row');
|
||
else moveSelection('home');
|
||
return;
|
||
case 'End':
|
||
ev.preventDefault();
|
||
if (ev.ctrlKey || ev.metaKey) moveSelection('end-row');
|
||
else moveSelection('end');
|
||
return;
|
||
case 'Tab':
|
||
ev.preventDefault();
|
||
moveSelection(ev.shiftKey ? 'left-wrap' : 'right-wrap');
|
||
return;
|
||
case 'Enter':
|
||
case 'F2':
|
||
ev.preventDefault();
|
||
enterEdit();
|
||
return;
|
||
case 'Escape':
|
||
ev.preventDefault();
|
||
clearSelection();
|
||
clearRange();
|
||
return;
|
||
case 'Delete':
|
||
case 'Backspace':
|
||
ev.preventDefault();
|
||
bulkClearSelection();
|
||
return;
|
||
case 'd':
|
||
case 'D':
|
||
if (ev.ctrlKey || ev.metaKey) {
|
||
ev.preventDefault();
|
||
bulkFill('down');
|
||
return;
|
||
}
|
||
break;
|
||
case 'r':
|
||
case 'R':
|
||
if (ev.ctrlKey || ev.metaKey) {
|
||
ev.preventDefault();
|
||
bulkFill('right');
|
||
return;
|
||
}
|
||
break;
|
||
}
|
||
|
||
if (isPrintableKey(ev)) {
|
||
// Replace value with the typed character (Excel convention).
|
||
ev.preventDefault();
|
||
enterEdit(ev.key);
|
||
}
|
||
}
|
||
|
||
// --- Range selection (multi-cell ops) -----------------------------
|
||
|
||
function extendRange(dir) {
|
||
if (!app.state.selected) return;
|
||
const range = ensureRange();
|
||
let { row: r, col: c } = range.focus;
|
||
const total = rowCount();
|
||
const cols = colCount();
|
||
switch (dir) {
|
||
case 'up': r = Math.max(0, r - 1); break;
|
||
case 'down': r = Math.min(total - 1, r + 1); break;
|
||
case 'left': c = Math.max(0, c - 1); break;
|
||
case 'right': c = Math.min(cols - 1, c + 1); break;
|
||
}
|
||
range.focus = { row: r, col: c };
|
||
applyRangeSelectionStyles(range);
|
||
}
|
||
|
||
function ensureRange() {
|
||
if (!app.state.range) {
|
||
const sel = app.state.selected;
|
||
app.state.range = {
|
||
anchor: { row: sel.row, col: sel.col },
|
||
focus: { row: sel.row, col: sel.col },
|
||
};
|
||
}
|
||
return app.state.range;
|
||
}
|
||
|
||
function clearRange() {
|
||
app.state.range = null;
|
||
const t = tableEl();
|
||
if (!t) return;
|
||
const all = t.querySelectorAll('[role="gridcell"]');
|
||
for (let i = 0; i < all.length; i++) {
|
||
all[i].classList.remove('zddc-table__cell--in-range');
|
||
}
|
||
}
|
||
|
||
function applyRangeSelectionStyles(range) {
|
||
const t = tableEl();
|
||
if (!t) return;
|
||
const all = t.querySelectorAll('[role="gridcell"]');
|
||
for (let i = 0; i < all.length; i++) {
|
||
all[i].classList.remove('zddc-table__cell--in-range');
|
||
}
|
||
const r0 = Math.min(range.anchor.row, range.focus.row);
|
||
const r1 = Math.max(range.anchor.row, range.focus.row);
|
||
const c0 = Math.min(range.anchor.col, range.focus.col);
|
||
const c1 = Math.max(range.anchor.col, range.focus.col);
|
||
for (let r = r0; r <= r1; r++) {
|
||
for (let c = c0; c <= c1; c++) {
|
||
const cell = cellAt(r, c);
|
||
if (cell) cell.classList.add('zddc-table__cell--in-range');
|
||
}
|
||
}
|
||
}
|
||
|
||
function rangeCells() {
|
||
// Returns an array of {rowIdx, colIdx, row, col} for every
|
||
// cell in the current range — or just the selected cell if
|
||
// no range is active. Skips cells whose row data can't be
|
||
// resolved (defensive).
|
||
const out = [];
|
||
const range = app.state.range;
|
||
if (range) {
|
||
const r0 = Math.min(range.anchor.row, range.focus.row);
|
||
const r1 = Math.max(range.anchor.row, range.focus.row);
|
||
const c0 = Math.min(range.anchor.col, range.focus.col);
|
||
const c1 = Math.max(range.anchor.col, range.focus.col);
|
||
for (let r = r0; r <= r1; r++) {
|
||
const row = rowDataAt(r);
|
||
if (!row) continue;
|
||
for (let c = c0; c <= c1; c++) {
|
||
const col = colAt(c);
|
||
if (col) out.push({ rowIdx: r, colIdx: c, row: row, col: col });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
if (!app.state.selected) return out;
|
||
const { row: r, col: c } = app.state.selected;
|
||
const row = rowDataAt(r);
|
||
const col = colAt(c);
|
||
if (row && col) out.push({ rowIdx: r, colIdx: c, row: row, col: col });
|
||
return out;
|
||
}
|
||
|
||
function bulkClearSelection() {
|
||
// Delete / Backspace in nav mode: clear every selected cell.
|
||
// Pushes one undo Command spanning all affected cells.
|
||
const cells = rangeCells();
|
||
if (cells.length === 0) return;
|
||
const undoCells = [];
|
||
for (let i = 0; i < cells.length; i++) {
|
||
const c = cells[i];
|
||
const oldRaw = app.modules.util.resolveField(c.row.data, c.col.field);
|
||
const priorDraft = getDraft(rowKey(c.row), c.col.field);
|
||
const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw;
|
||
setDraft(rowKey(c.row), c.col.field, null);
|
||
undoCells.push({
|
||
rowId: rowKey(c.row),
|
||
field: c.col.field,
|
||
oldValue: undoOld,
|
||
newValue: null,
|
||
});
|
||
}
|
||
const undoMod = app.modules.undo;
|
||
if (undoMod) undoMod.push({ cells: undoCells });
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
}
|
||
|
||
function bulkFill(dir) {
|
||
// Ctrl+D fills the top row's values down through the range.
|
||
// Ctrl+R fills the left column's values right through the range.
|
||
// No-op when no range is active (Excel does the same).
|
||
const range = app.state.range;
|
||
if (!range) return;
|
||
const r0 = Math.min(range.anchor.row, range.focus.row);
|
||
const r1 = Math.max(range.anchor.row, range.focus.row);
|
||
const c0 = Math.min(range.anchor.col, range.focus.col);
|
||
const c1 = Math.max(range.anchor.col, range.focus.col);
|
||
|
||
const undoCells = [];
|
||
for (let r = r0; r <= r1; r++) {
|
||
const row = rowDataAt(r);
|
||
if (!row) continue;
|
||
for (let c = c0; c <= c1; c++) {
|
||
const col = colAt(c);
|
||
if (!col) continue;
|
||
const srcR = (dir === 'down') ? r0 : r;
|
||
const srcC = (dir === 'right') ? c0 : c;
|
||
if (r === srcR && c === srcC) continue;
|
||
const srcRow = rowDataAt(srcR);
|
||
const srcCol = colAt(srcC);
|
||
if (!srcRow || !srcCol) continue;
|
||
const value = effectiveCellValue(srcRow, srcCol);
|
||
const oldRaw = app.modules.util.resolveField(row.data, col.field);
|
||
const priorDraft = getDraft(rowKey(row), col.field);
|
||
const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw;
|
||
setDraft(rowKey(row), col.field, value);
|
||
undoCells.push({
|
||
rowId: rowKey(row),
|
||
field: col.field,
|
||
oldValue: undoOld,
|
||
newValue: value,
|
||
});
|
||
}
|
||
}
|
||
if (undoCells.length > 0) {
|
||
const undoMod = app.modules.undo;
|
||
if (undoMod) undoMod.push({ cells: undoCells });
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
}
|
||
}
|
||
|
||
// --- Wiring -------------------------------------------------------
|
||
|
||
function attachToTable() {
|
||
const t = tableEl();
|
||
if (!t) return;
|
||
t.setAttribute('role', 'grid');
|
||
t.addEventListener('keydown', onCellKey);
|
||
}
|
||
|
||
function attachToRow(tr, rowId) {
|
||
tr.setAttribute('role', 'row');
|
||
tr.setAttribute('data-row-id', rowId);
|
||
}
|
||
|
||
function attachToCell(td, rowIdx, colIdx) {
|
||
td.setAttribute('role', 'gridcell');
|
||
td.setAttribute('data-col-idx', String(colIdx));
|
||
td.setAttribute('data-row-idx', String(rowIdx));
|
||
td.setAttribute('tabindex', '-1');
|
||
|
||
td.addEventListener('click', function (ev) {
|
||
ev.stopPropagation();
|
||
if (ev.shiftKey && app.state.selected) {
|
||
// Shift+click extends the range from the existing
|
||
// anchor to the clicked cell.
|
||
const range = ensureRange();
|
||
range.focus = { row: rowIdx, col: colIdx };
|
||
applyRangeSelectionStyles(range);
|
||
// Move tabindex/focus marker to the clicked cell but
|
||
// keep the anchor in place.
|
||
setSelected(rowIdx, colIdx, { keepRange: true });
|
||
} else {
|
||
clearRange();
|
||
setSelected(rowIdx, colIdx);
|
||
}
|
||
});
|
||
td.addEventListener('dblclick', function (ev) {
|
||
ev.stopPropagation();
|
||
clearRange();
|
||
setSelected(rowIdx, colIdx, { noFocus: true });
|
||
enterEdit();
|
||
});
|
||
}
|
||
|
||
app.modules.editor = {
|
||
attachToTable: attachToTable,
|
||
attachToRow: attachToRow,
|
||
attachToCell: attachToCell,
|
||
setSelected: setSelected,
|
||
clearSelection: clearSelection,
|
||
moveSelection: moveSelection,
|
||
enterEdit: enterEdit,
|
||
rowKey: rowKey,
|
||
getDraft: getDraft,
|
||
setDraft: setDraft,
|
||
clearDraftField: clearDraftField,
|
||
effectiveCellValue: effectiveCellValue
|
||
};
|
||
})(window.tablesApp);
|
||
|
||
// undo.js — Phase 5 of editable-cell mode.
|
||
//
|
||
// Linear command stack, depth 50, session-local. Every successful
|
||
// per-cell edit and every bulk operation (paste, fill, delete) push
|
||
// a Command onto the stack. Ctrl/Cmd+Z pops the most recent and
|
||
// replays the inverse — sets each affected cell's draft buffer
|
||
// back to its `oldValue` (or clears the draft when oldValue was
|
||
// the row's stored value), then triggers a re-paint and the
|
||
// row-blur save flow picks the change up like any other edit.
|
||
//
|
||
// Why local-only: shared undo across multiple users is conceptually
|
||
// broken under last-writer-wins (undoing my edit might revert
|
||
// someone else's intervening edit). Every production grid keeps
|
||
// undo per-tab; we follow.
|
||
//
|
||
// Why no redo: minimum viable. Adding redo is a parallel forward
|
||
// stack cleared on any new edit. Cheap to add later if users miss
|
||
// it.
|
||
//
|
||
// Command shape:
|
||
// { cells: [ {rowId, field, oldValue, newValue}, ... ] }
|
||
//
|
||
// One-cell edits push a single-cell Command. Bulk operations push
|
||
// one Command with N cells so a single Ctrl+Z reverts the whole
|
||
// group.
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const STACK_MAX = 50;
|
||
const _stack = [];
|
||
|
||
function push(cmd) {
|
||
if (!cmd || !cmd.cells || cmd.cells.length === 0) return;
|
||
_stack.push(cmd);
|
||
if (_stack.length > STACK_MAX) {
|
||
_stack.shift();
|
||
}
|
||
}
|
||
|
||
function depth() { return _stack.length; }
|
||
|
||
function clear() { _stack.length = 0; }
|
||
|
||
function undo() {
|
||
const cmd = _stack.pop();
|
||
if (!cmd || !cmd.cells || cmd.cells.length === 0) return null;
|
||
|
||
const editor = app.modules.editor;
|
||
if (!editor) return null;
|
||
|
||
for (let i = 0; i < cmd.cells.length; i++) {
|
||
const c = cmd.cells[i];
|
||
// Compare oldValue to the row's stored data — if they
|
||
// match, clear the draft (the user's edit is being
|
||
// reversed back to baseline). Otherwise set draft = old.
|
||
const row = findRow(c.rowId);
|
||
if (!row) continue;
|
||
const stored = app.modules.util.resolveField(row.data, c.field);
|
||
if (sameValue(stored, c.oldValue)) {
|
||
editor.clearDraftField(c.rowId, c.field);
|
||
} else {
|
||
editor.setDraft(c.rowId, c.field, c.oldValue);
|
||
}
|
||
}
|
||
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
return cmd;
|
||
}
|
||
|
||
function findRow(rowId) {
|
||
const editor = app.modules.editor;
|
||
const all = (app.state && app.state.rows) || [];
|
||
for (let i = 0; i < all.length; i++) {
|
||
if (editor.rowKey(all[i]) === rowId) return all[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function sameValue(a, b) {
|
||
if (a === b) return true;
|
||
if (a == null && b == null) return true;
|
||
if (a == null || b == null) return false;
|
||
if (typeof a === 'object' || typeof b === 'object') {
|
||
try { return JSON.stringify(a) === JSON.stringify(b); }
|
||
catch (_) { return false; }
|
||
}
|
||
return String(a) === String(b);
|
||
}
|
||
|
||
// Hotkey: Ctrl+Z (Cmd+Z on macOS). Bound at the document level
|
||
// so the user can undo from anywhere on the page, not just from
|
||
// within a focused cell.
|
||
function onKey(ev) {
|
||
const isMod = ev.ctrlKey || ev.metaKey;
|
||
if (!isMod) return;
|
||
if (ev.key === 'z' || ev.key === 'Z') {
|
||
// Skip when the active element is a text-input-like; we
|
||
// don't want to override the browser's intra-input undo.
|
||
const ae = document.activeElement;
|
||
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) {
|
||
return;
|
||
}
|
||
ev.preventDefault();
|
||
undo();
|
||
}
|
||
}
|
||
document.addEventListener('keydown', onKey);
|
||
|
||
app.modules.undo = {
|
||
push: push,
|
||
undo: undo,
|
||
depth: depth,
|
||
clear: clear,
|
||
};
|
||
})(window.tablesApp);
|
||
|
||
// add-row.js — inline new-row creation.
|
||
//
|
||
// Click "+ Add row" → append a draft row at the end of state.rows,
|
||
// focus its first editable cell, accumulate user typing into the
|
||
// drafts buffer like any other row. On row-blur, save.js detects the
|
||
// row.isNew flag and POSTs to <dir>/form.html (the form-create
|
||
// endpoint). The 201 response carries the new row's Location; we swap
|
||
// the synthetic url/yamlUrl for the real ones and the draft row
|
||
// becomes a normal saved row.
|
||
//
|
||
// Synthetic identity: each new row gets a temporary "__new-<N>" url
|
||
// so rowKey() returns something unique for selection + draft tracking.
|
||
// The temporary url is replaced after a successful POST. There is no
|
||
// "save on click" UX — the existing row-blur trigger is the save path,
|
||
// same as for edits.
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
let _counter = 0;
|
||
|
||
function makeSyntheticKey() {
|
||
_counter += 1;
|
||
return '__new-' + _counter;
|
||
}
|
||
|
||
// Compute the form-create URL for the current page. Both
|
||
// /<dir>/table.html and /<dir>/ (default_tool: tables) shape work;
|
||
// /<dir>/form.html is the form handler's "create" endpoint either
|
||
// way (the form handler keys off the in-dir convention, not the
|
||
// visiting URL shape).
|
||
function formCreateUrl() {
|
||
let dir = (location.pathname || '/').replace(/\/table\.html$/, '/');
|
||
if (!dir.endsWith('/')) dir += '/';
|
||
return dir + 'form.html';
|
||
}
|
||
|
||
// Create-and-paint: the user-facing path.
|
||
function invoke() {
|
||
const key = createSilent();
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
focusNewRow(key);
|
||
}
|
||
|
||
// Push a draft row WITHOUT painting or focusing. Used by multi-row
|
||
// paste (clipboard.js) to create N rows in a single batch, with one
|
||
// paint at the end. Returns the synthetic url so callers can address
|
||
// the new row in their draft writes.
|
||
function createSilent() {
|
||
const key = makeSyntheticKey();
|
||
const draftRow = {
|
||
url: key,
|
||
yamlUrl: null,
|
||
data: {},
|
||
etag: null,
|
||
editable: true,
|
||
isNew: true,
|
||
};
|
||
if (!Array.isArray(app.state.rows)) {
|
||
app.state.rows = [];
|
||
}
|
||
app.state.rows.push(draftRow);
|
||
return key;
|
||
}
|
||
|
||
function focusNewRow(key) {
|
||
// After repaint, find the tr with our synthetic data-row-id and
|
||
// tell the editor to select its first cell. Filtering may have
|
||
// hidden the new row if a default filter excludes it; we accept
|
||
// that — clearing filters surfaces it.
|
||
const tbody = document.querySelector('#table-root tbody');
|
||
if (!tbody) return;
|
||
const trs = tbody.querySelectorAll('tr');
|
||
for (let i = 0; i < trs.length; i++) {
|
||
if (trs[i].getAttribute('data-row-id') === key) {
|
||
const editor = app.modules.editor;
|
||
if (editor && typeof editor.setSelected === 'function') {
|
||
// Scroll into view so the user sees the new row.
|
||
trs[i].scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||
editor.setSelected(i, 0);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Cancel-new-row helper: drop the synthetic row entirely. Used when
|
||
// the user adds a row, makes no edits, and clicks Add again or
|
||
// navigates away — there's nothing to save and an empty draft just
|
||
// clutters the table. The save module calls this from row-blur when
|
||
// it sees a new row with no drafts.
|
||
function discardEmpty(rowId) {
|
||
const rows = app.state.rows || [];
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (rows[i].isNew && rows[i].url === rowId) {
|
||
rows.splice(i, 1);
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
app.modules.addRow = {
|
||
invoke: invoke,
|
||
createSilent: createSilent,
|
||
formCreateUrl: formCreateUrl,
|
||
discardEmpty: discardEmpty,
|
||
};
|
||
})(window.tablesApp);
|
||
|
||
// save.js — Phase 3 of editable-cell mode.
|
||
//
|
||
// Row-level batch save on row-blur. While the user is editing cells
|
||
// inside a row, draft values accumulate in app.state.drafts. When the
|
||
// editor's selection moves to a different row (or focus leaves the
|
||
// grid entirely), this module fires one PUT for the row that lost
|
||
// focus, with full merged data + If-Match for the row's tracked ETag.
|
||
//
|
||
// Three response paths:
|
||
//
|
||
// - 200 / 201 / 202: success or queued-offline (cache outbox).
|
||
// Drafts clear, row.data merges, new ETag captured. Row's
|
||
// "dirty" indicator drops.
|
||
//
|
||
// - 412 Precondition Failed: someone else changed this row since
|
||
// we read it. Drafts STAY — never silently discard the user's
|
||
// typing. Row gets a "stale" badge with [Use mine] / [Reload]
|
||
// in the page status bar. "Use mine" re-GETs the row to pick up
|
||
// the new ETag and server data, replays drafts on top, re-PUTs
|
||
// (this is the client-side field-level LWW trick from the
|
||
// architecture report — fields the user didn't touch get the
|
||
// server's new values automatically). "Reload" drops drafts and
|
||
// refreshes from server.
|
||
//
|
||
// - 422 Unprocessable Entity: server-side schema validation failed.
|
||
// Body is {errors: [{path, message}, ...]}. Each path → field,
|
||
// marked with a red corner on the cell. Drafts stay so the user
|
||
// can correct in place.
|
||
//
|
||
// - Other (4xx / 5xx / network): row marked errored with the
|
||
// status code; drafts stay.
|
||
//
|
||
// Outbox transparency: when running through a downstream client, the
|
||
// PUT is intercepted by the cache layer; on local network failure
|
||
// it's queued and the response is 202 Accepted with X-ZDDC-Cache:
|
||
// queued. We treat 202 as success-ish — drafts clear, indicator
|
||
// shows a small "queued" badge so the user knows the write hasn't
|
||
// reached upstream yet.
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
function modules() {
|
||
return app.modules.editor;
|
||
}
|
||
|
||
function findRowById(rowId) {
|
||
const all = (app.state && app.state.rows) || [];
|
||
for (let i = 0; i < all.length; i++) {
|
||
if (modules().rowKey(all[i]) === rowId) return all[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function mergeRow(data, drafts) {
|
||
// Shallow merge: drafts are field-level overrides on the row's
|
||
// top-level data object. Phase 2's complex-type cells punt to
|
||
// form-mode and never produce drafts here, so drafts only
|
||
// contain primitive / string-array values that are safe to
|
||
// overwrite the corresponding top-level field.
|
||
//
|
||
// $-prefixed keys are system-synthesised on read (e.g. `$party`
|
||
// injected by the server's virtual-view handler on project-
|
||
// rollup MDL/RSK rows). They are not part of the row's stored
|
||
// YAML and would be rejected by the schema's additionalProperties
|
||
// rule. Strip them before sending the write.
|
||
const merged = Object.assign({}, data || {}, drafts || {});
|
||
for (const k of Object.keys(merged)) {
|
||
if (k.charAt(0) === '$') delete merged[k];
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
function rowFromState(rowId) {
|
||
return {
|
||
row: findRowById(rowId),
|
||
drafts: (app.state.drafts && app.state.drafts[rowId]) || null,
|
||
};
|
||
}
|
||
|
||
// --- Visual state markers ----------------------------------------
|
||
|
||
function setRowState(rowId, stateName) {
|
||
// Apply a CSS state class to the row matching rowId. States:
|
||
// "" / null — no marker
|
||
// "dirty" — has uncommitted drafts
|
||
// "saving" — PUT in flight
|
||
// "stale" — server returned 412
|
||
// "errored" — server returned 4xx/5xx other than 412/422
|
||
// "queued" — write went into the outbox
|
||
// "invalid" — server returned 422
|
||
const tbody = document.querySelector('#table-root tbody');
|
||
if (!tbody) return;
|
||
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
|
||
if (!tr) return;
|
||
const stateClasses = ['dirty', 'saving', 'stale', 'errored', 'queued', 'invalid'];
|
||
for (let i = 0; i < stateClasses.length; i++) {
|
||
tr.classList.remove('zddc-table__row--' + stateClasses[i]);
|
||
}
|
||
if (stateName) tr.classList.add('zddc-table__row--' + stateName);
|
||
}
|
||
|
||
function markCellInvalid(rowId, field, message) {
|
||
const tbody = document.querySelector('#table-root tbody');
|
||
if (!tbody) return;
|
||
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
|
||
if (!tr) return;
|
||
// Walk the column list to find the field's column index;
|
||
// data-col-idx is the numeric position rendered into each td.
|
||
const cols = (app.context && app.context.columns) || [];
|
||
let idx = -1;
|
||
for (let i = 0; i < cols.length; i++) {
|
||
if (cols[i].field === field) { idx = i; break; }
|
||
}
|
||
if (idx < 0) return;
|
||
const target = tr.querySelector('[role="gridcell"][data-col-idx="' + idx + '"]');
|
||
if (!target) return;
|
||
target.classList.add('zddc-table__cell--invalid');
|
||
if (message) target.setAttribute('title', message);
|
||
}
|
||
|
||
function clearCellInvalid(rowId) {
|
||
const tbody = document.querySelector('#table-root tbody');
|
||
if (!tbody) return;
|
||
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
|
||
if (!tr) return;
|
||
const invalids = tr.querySelectorAll('.zddc-table__cell--invalid');
|
||
for (let i = 0; i < invalids.length; i++) {
|
||
invalids[i].classList.remove('zddc-table__cell--invalid');
|
||
invalids[i].removeAttribute('title');
|
||
}
|
||
}
|
||
|
||
function cssEscape(s) {
|
||
// CSS.escape if available; otherwise a defensive escape for
|
||
// the characters that appear in URL paths used as data-row-id
|
||
// values. Browsers everywhere modern enough to support the
|
||
// FS Access API have CSS.escape, so this is mostly defensive.
|
||
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(s);
|
||
return String(s).replace(/[^a-zA-Z0-9_-]/g, function (ch) {
|
||
return '\\' + ch;
|
||
});
|
||
}
|
||
|
||
// --- Status bar (stale-row prompt) --------------------------------
|
||
|
||
function showStatusPrompt(rowId, message, actions) {
|
||
// Renders into #table-status (hidden by default per template).
|
||
// actions = [{label, onClick}, ...]
|
||
const el = document.getElementById('table-status');
|
||
if (!el) return;
|
||
el.textContent = '';
|
||
el.classList.add('table-status--prompt');
|
||
const span = document.createElement('span');
|
||
span.textContent = message;
|
||
el.appendChild(span);
|
||
for (let i = 0; i < (actions || []).length; i++) {
|
||
const a = actions[i];
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'btn btn-secondary btn-sm';
|
||
btn.textContent = a.label;
|
||
btn.addEventListener('click', a.onClick);
|
||
el.appendChild(btn);
|
||
}
|
||
const dismiss = document.createElement('button');
|
||
dismiss.type = 'button';
|
||
dismiss.className = 'btn btn-secondary btn-sm';
|
||
dismiss.textContent = '×';
|
||
dismiss.title = 'Dismiss';
|
||
dismiss.addEventListener('click', clearStatus);
|
||
el.appendChild(dismiss);
|
||
el.hidden = false;
|
||
el.setAttribute('data-row-id', rowId);
|
||
}
|
||
|
||
function clearStatus() {
|
||
const el = document.getElementById('table-status');
|
||
if (!el) return;
|
||
el.textContent = '';
|
||
el.hidden = true;
|
||
el.removeAttribute('data-row-id');
|
||
el.classList.remove('table-status--prompt');
|
||
}
|
||
|
||
// --- The save itself ---------------------------------------------
|
||
|
||
async function saveRow(rowId, opts) {
|
||
opts = opts || {};
|
||
const { row, drafts } = rowFromState(rowId);
|
||
if (!row) return { status: 'noop' };
|
||
const hasDrafts = drafts && Object.keys(drafts).length > 0;
|
||
// New (unsaved) rows: if the user added a row and then moved on
|
||
// without typing anything, drop the empty placeholder rather
|
||
// than POST an empty body that fails schema validation.
|
||
if (row.isNew && !hasDrafts) {
|
||
const addRow = app.modules.addRow;
|
||
if (addRow && typeof addRow.discardEmpty === 'function') {
|
||
addRow.discardEmpty(rowId);
|
||
}
|
||
return { status: 'discarded-empty' };
|
||
}
|
||
if (!hasDrafts) return { status: 'noop' };
|
||
if (row.isNew) {
|
||
return createRow(rowId, row, drafts, opts);
|
||
}
|
||
if (!row.yamlUrl) {
|
||
// file:// mode or rows from inline-context test fixtures
|
||
// don't have a URL to PUT to — bail silently.
|
||
return { status: 'no-url' };
|
||
}
|
||
if (row.editable === false) {
|
||
// Row is read-only per the server. Don't even try.
|
||
return { status: 'readonly' };
|
||
}
|
||
|
||
setRowState(rowId, 'saving');
|
||
const merged = mergeRow(row.data, drafts);
|
||
const yamlBody = window.jsyaml.dump(merged);
|
||
|
||
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
|
||
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
|
||
|
||
const fetchOpts = {
|
||
method: 'PUT',
|
||
body: yamlBody,
|
||
headers: headers,
|
||
credentials: 'same-origin',
|
||
};
|
||
// The unload path passes keepalive:true so the PUT outlives the
|
||
// page navigation. Subject to the spec's 64 KB body cap — large
|
||
// rows may fail in that path; normal saves are unaffected.
|
||
if (opts.keepalive) fetchOpts.keepalive = true;
|
||
|
||
let resp;
|
||
try {
|
||
resp = await fetch(row.yamlUrl, fetchOpts);
|
||
} catch (err) {
|
||
// Network failure — outbox-fronted client should still
|
||
// resolve with 202; reaching here means a hard client-side
|
||
// network error. Mark errored, drafts stay.
|
||
console.error('[tables] save network error', err);
|
||
setRowState(rowId, 'errored');
|
||
return { status: 'network-error', error: err };
|
||
}
|
||
|
||
if (resp.status === 200 || resp.status === 201) {
|
||
// Success: clear drafts + invalid marks, capture new ETag.
|
||
const newEtag = resp.headers.get('ETag');
|
||
if (newEtag) row.etag = newEtag.replace(/"/g, '');
|
||
// For record-typed writes the server echoes the stamped
|
||
// YAML (with server-managed audit fields) back as the
|
||
// response body — parse it and overwrite row.data so the
|
||
// table sees the same bytes that just landed on disk.
|
||
// Falls back to the local merge when the server didn't
|
||
// echo a body (non-record write or older server).
|
||
let serverData = null;
|
||
const ct = (resp.headers.get('Content-Type') || '').toLowerCase();
|
||
if (ct.includes('yaml') && window.jsyaml) {
|
||
try {
|
||
const text = await resp.text();
|
||
if (text && text.trim()) serverData = window.jsyaml.load(text);
|
||
} catch (e) {
|
||
console.warn('[tables] server response YAML parse failed; using local merge', e);
|
||
}
|
||
}
|
||
row.data = serverData || merged;
|
||
delete app.state.drafts[rowId];
|
||
clearCellInvalid(rowId);
|
||
setRowState(rowId, '');
|
||
// If a status prompt was up for this row, drop it.
|
||
const sb = document.getElementById('table-status');
|
||
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
|
||
updateSaveButton();
|
||
return { status: 'ok' };
|
||
}
|
||
|
||
if (resp.status === 202) {
|
||
// Outbox queued. Drafts clear (they're persisted in the
|
||
// outbox; the server will replay them on reconnect), but
|
||
// the row stays marked queued so the user knows.
|
||
row.data = merged;
|
||
delete app.state.drafts[rowId];
|
||
setRowState(rowId, 'queued');
|
||
updateSaveButton();
|
||
return { status: 'queued' };
|
||
}
|
||
|
||
if (resp.status === 412) {
|
||
// Precondition Failed — someone else changed the row.
|
||
// Drafts STAY. Surface the prompt.
|
||
setRowState(rowId, 'stale');
|
||
showStatusPrompt(
|
||
rowId,
|
||
'This row was changed by someone else. ',
|
||
[
|
||
{ label: 'Use mine', onClick: () => useMine(rowId) },
|
||
{ label: 'Reload', onClick: () => reload(rowId) },
|
||
]
|
||
);
|
||
return { status: 'conflict' };
|
||
}
|
||
|
||
if (resp.status === 422) {
|
||
// Validation errors. Body shape matches the form system's
|
||
// 422 response: {errors: [{path: "/field", message}, ...]}.
|
||
let body = {};
|
||
try { body = await resp.json(); } catch (_) { /* ignore */ }
|
||
clearCellInvalid(rowId);
|
||
const errs = body.errors || [];
|
||
for (let i = 0; i < errs.length; i++) {
|
||
const e = errs[i];
|
||
const field = String(e.path || '').replace(/^\//, '').split('/')[0];
|
||
if (field) markCellInvalid(rowId, field, e.message || 'invalid');
|
||
}
|
||
setRowState(rowId, 'invalid');
|
||
return { status: 'invalid', errors: errs };
|
||
}
|
||
|
||
if (resp.status === 403) {
|
||
setRowState(rowId, 'errored');
|
||
if (window.zddc && window.zddc.cap) {
|
||
window.zddc.cap.handleForbidden(resp, {
|
||
context: 'Save row',
|
||
path: location.pathname
|
||
});
|
||
}
|
||
return { status: 'forbidden' };
|
||
}
|
||
|
||
// Other status — generic error.
|
||
console.warn('[tables] save returned', resp.status);
|
||
setRowState(rowId, 'errored');
|
||
return { status: 'http-error', code: resp.status };
|
||
}
|
||
|
||
// createRow handles the POST path for an isNew row. Body is YAML of
|
||
// the row's draft data (no row.data yet — it's a fresh row). Success
|
||
// is 201 + Location pointing at the new <id>.yaml; we swap the
|
||
// synthetic url/yamlUrl for the real ones and clear isNew so the
|
||
// row behaves like any other from this point on.
|
||
async function createRow(rowId, row, drafts, opts) {
|
||
const addRow = app.modules.addRow;
|
||
if (!addRow || typeof addRow.formCreateUrl !== 'function') {
|
||
setRowState(rowId, 'errored');
|
||
return { status: 'no-create-url' };
|
||
}
|
||
const createUrl = addRow.formCreateUrl();
|
||
const merged = mergeRow(row.data, drafts);
|
||
const yamlBody = window.jsyaml.dump(merged);
|
||
|
||
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
|
||
const fetchOpts = {
|
||
method: 'POST',
|
||
body: yamlBody,
|
||
headers: headers,
|
||
credentials: 'same-origin',
|
||
};
|
||
if (opts && opts.keepalive) fetchOpts.keepalive = true;
|
||
|
||
setRowState(rowId, 'saving');
|
||
let resp;
|
||
try {
|
||
resp = await fetch(createUrl, fetchOpts);
|
||
} catch (err) {
|
||
console.error('[tables] createRow network error', err);
|
||
setRowState(rowId, 'errored');
|
||
return { status: 'network-error', error: err };
|
||
}
|
||
|
||
if (resp.status === 201) {
|
||
// Server wrote the row. Body is {location, filename}; we
|
||
// also accept the Location header if the body isn't JSON.
|
||
let body = {};
|
||
try { body = await resp.json(); } catch (_) { /* ignore */ }
|
||
const location = body.location || resp.headers.get('Location') || '';
|
||
const newEtag = (resp.headers.get('ETag') || '').replace(/"/g, '');
|
||
row.yamlUrl = location;
|
||
row.url = location ? location + '.html' : row.url;
|
||
// Re-fetch the just-written row so server-derived fields
|
||
// surface immediately: folder-bound originator, the composed
|
||
// tracking number's components, and audit stamps. The local
|
||
// `merged` lacks these (e.g. originator is read-only and
|
||
// never typed). Fall back to merged if the GET fails.
|
||
row.data = merged;
|
||
if (location) {
|
||
try {
|
||
const back = await fetch(location, { credentials: 'same-origin' });
|
||
if (back.ok) {
|
||
const text = await back.text();
|
||
if (text && text.trim() && window.jsyaml) {
|
||
row.data = window.jsyaml.load(text) || merged;
|
||
}
|
||
const fetchedEtag = (back.headers.get('ETag') || '').replace(/"/g, '');
|
||
if (fetchedEtag) row.etag = fetchedEtag;
|
||
}
|
||
} catch (e) {
|
||
console.warn('[tables] post-create re-fetch failed; using local merge', e);
|
||
}
|
||
}
|
||
if (!row.etag) row.etag = newEtag || null;
|
||
row.isNew = false;
|
||
// Move the drafts entry (was keyed on the synthetic id) to
|
||
// the new url, then clear it (data has the merged values).
|
||
delete app.state.drafts[rowId];
|
||
clearCellInvalid(rowId);
|
||
setRowState(rowId, '');
|
||
const sb = document.getElementById('table-status');
|
||
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
|
||
// Re-paint so the row picks up its new data-row-id and any
|
||
// server-supplied default fields surface.
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
return { status: 'ok' };
|
||
}
|
||
|
||
if (resp.status === 422) {
|
||
let body = {};
|
||
try { body = await resp.json(); } catch (_) { /* ignore */ }
|
||
clearCellInvalid(rowId);
|
||
const errs = body.errors || [];
|
||
for (let i = 0; i < errs.length; i++) {
|
||
const e = errs[i];
|
||
const field = String(e.path || '').replace(/^\//, '').split('/')[0];
|
||
if (field) markCellInvalid(rowId, field, e.message || 'invalid');
|
||
}
|
||
setRowState(rowId, 'invalid');
|
||
return { status: 'invalid', errors: errs };
|
||
}
|
||
|
||
if (resp.status === 403) {
|
||
setRowState(rowId, 'errored');
|
||
if (window.zddc && window.zddc.cap) {
|
||
window.zddc.cap.handleForbidden(resp, {
|
||
context: 'Add row',
|
||
path: location.pathname
|
||
});
|
||
}
|
||
return { status: 'forbidden' };
|
||
}
|
||
|
||
if (resp.status === 409) {
|
||
// The composed tracking number collides with an existing
|
||
// row (the server rejects duplicates). Surface it on the
|
||
// sequence cell — the usual disambiguator — rather than the
|
||
// generic errored state, so the user knows to bump a
|
||
// component instead of retrying the same values.
|
||
let msg = 'Duplicate tracking number — change a component (e.g. sequence).';
|
||
try { const t = await resp.text(); if (t && t.trim()) msg = t.trim(); } catch (_) { /* ignore */ }
|
||
clearCellInvalid(rowId);
|
||
markCellInvalid(rowId, 'sequence', msg);
|
||
setRowState(rowId, 'invalid');
|
||
return { status: 'duplicate', message: msg };
|
||
}
|
||
|
||
console.warn('[tables] createRow returned', resp.status);
|
||
setRowState(rowId, 'errored');
|
||
return { status: 'http-error', code: resp.status };
|
||
}
|
||
|
||
async function useMine(rowId) {
|
||
const { row, drafts } = rowFromState(rowId);
|
||
if (!row || !drafts) return;
|
||
// Re-GET the row to learn the latest server state + ETag.
|
||
try {
|
||
const resp = await fetch(row.yamlUrl, { credentials: 'same-origin' });
|
||
if (!resp.ok) {
|
||
console.warn('[tables] reload on conflict failed', resp.status);
|
||
return;
|
||
}
|
||
const text = await resp.text();
|
||
const fresh = window.jsyaml.load(text) || {};
|
||
row.data = fresh;
|
||
const newEtag = resp.headers.get('ETag');
|
||
row.etag = newEtag ? newEtag.replace(/"/g, '') : null;
|
||
} catch (err) {
|
||
console.error('[tables] reload on conflict error', err);
|
||
return;
|
||
}
|
||
// Drafts preserved — replay against the new base.
|
||
return saveRow(rowId);
|
||
}
|
||
|
||
async function reload(rowId) {
|
||
const row = findRowById(rowId);
|
||
if (!row) return;
|
||
try {
|
||
const resp = await fetch(row.yamlUrl, { credentials: 'same-origin' });
|
||
if (!resp.ok) return;
|
||
const text = await resp.text();
|
||
row.data = window.jsyaml.load(text) || {};
|
||
const newEtag = resp.headers.get('ETag');
|
||
row.etag = newEtag ? newEtag.replace(/"/g, '') : null;
|
||
} catch (_) { return; }
|
||
delete app.state.drafts[rowId];
|
||
clearCellInvalid(rowId);
|
||
setRowState(rowId, '');
|
||
clearStatus();
|
||
// Trigger a re-paint via the public app callback if one exists.
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
}
|
||
|
||
// --- Trigger: row-blur ------------------------------------------
|
||
|
||
let _previousSelectedRowId = null;
|
||
|
||
function trackSelectionChange(prevRowId, nextRowId) {
|
||
// Fires when the editor's selection changes rows. If prevRow
|
||
// had drafts, save it now. nextRow can be null (focus left
|
||
// the grid) — also a save trigger.
|
||
if (prevRowId && prevRowId !== nextRowId) {
|
||
const drafts = app.state.drafts && app.state.drafts[prevRowId];
|
||
if (drafts && Object.keys(drafts).length > 0) {
|
||
// Fire and forget. The user has moved on; we don't
|
||
// want to block their flow waiting for the server.
|
||
saveRow(prevRowId).catch(err => {
|
||
console.error('[tables] saveRow rejection', err);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function onSelectionChanged(selected) {
|
||
const prevRowId = _previousSelectedRowId;
|
||
const nextRowId = selected ? rowIdAtIndex(selected.row) : null;
|
||
if (prevRowId !== nextRowId) {
|
||
trackSelectionChange(prevRowId, nextRowId);
|
||
_previousSelectedRowId = nextRowId;
|
||
}
|
||
// Mark dirty rows visually whenever selection settles.
|
||
markAllDirtyRows();
|
||
}
|
||
|
||
function rowIdAtIndex(visibleRowIdx) {
|
||
const tr = document.querySelectorAll('#table-root tbody > tr')[visibleRowIdx];
|
||
return tr ? tr.getAttribute('data-row-id') : null;
|
||
}
|
||
|
||
function markAllDirtyRows() {
|
||
// After a re-paint or selection change, re-apply dirty state
|
||
// to any row that has drafts (CSS classes don't survive
|
||
// tbody.innerHTML='' in renderBody).
|
||
const drafts = app.state.drafts || {};
|
||
const tbody = document.querySelector('#table-root tbody');
|
||
if (!tbody) return;
|
||
const trs = tbody.querySelectorAll('tr');
|
||
for (let i = 0; i < trs.length; i++) {
|
||
const tr = trs[i];
|
||
const rowId = tr.getAttribute('data-row-id');
|
||
if (rowId && drafts[rowId] && Object.keys(drafts[rowId]).length > 0) {
|
||
if (!tr.classList.contains('zddc-table__row--saving') &&
|
||
!tr.classList.contains('zddc-table__row--stale') &&
|
||
!tr.classList.contains('zddc-table__row--invalid') &&
|
||
!tr.classList.contains('zddc-table__row--errored') &&
|
||
!tr.classList.contains('zddc-table__row--queued')) {
|
||
tr.classList.add('zddc-table__row--dirty');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function flushAllDrafts() {
|
||
// Page-unload safety net. Best-effort: any row with drafts
|
||
// gets one final save attempt. fetch() is async, the page may
|
||
// already be navigating; we just kick the requests off.
|
||
const drafts = app.state.drafts || {};
|
||
const ids = Object.keys(drafts);
|
||
for (let i = 0; i < ids.length; i++) {
|
||
saveRow(ids[i], { keepalive: true }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
// flushAll fires saves for every dirty row and returns when they
|
||
// all settle. Used by the explicit Save button and the auto-save
|
||
// when focus leaves the grid. Unlike flushAllDrafts, this is NOT
|
||
// keepalive — the page isn't going anywhere, so we wait for real
|
||
// responses and surface errors normally.
|
||
async function flushAll() {
|
||
const drafts = app.state.drafts || {};
|
||
const ids = Object.keys(drafts).filter(id => drafts[id] && Object.keys(drafts[id]).length > 0);
|
||
if (ids.length === 0) return { status: 'noop' };
|
||
const results = await Promise.allSettled(ids.map(id => saveRow(id)));
|
||
const ok = results.filter(r => r.status === 'fulfilled' && r.value && r.value.status === 'ok').length;
|
||
return { status: 'done', total: ids.length, ok: ok, failed: ids.length - ok };
|
||
}
|
||
|
||
// Count rows that have at least one unsaved field.
|
||
function dirtyCount() {
|
||
const drafts = app.state.drafts || {};
|
||
let n = 0;
|
||
for (const id in drafts) {
|
||
if (drafts[id] && Object.keys(drafts[id]).length > 0) n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
// Update the toolbar Save button visibility + label from current
|
||
// draft state. Called from editor.js whenever drafts mutate; also
|
||
// safe to call anytime (e.g. after a paint).
|
||
function updateSaveButton() {
|
||
const btn = document.getElementById('table-save');
|
||
if (!btn) return;
|
||
const n = dirtyCount();
|
||
if (n === 0) {
|
||
btn.hidden = true;
|
||
btn.textContent = 'Save';
|
||
return;
|
||
}
|
||
btn.hidden = false;
|
||
btn.textContent = n === 1 ? 'Save (1 unsaved)' : 'Save (' + n + ' unsaved)';
|
||
}
|
||
|
||
function onDraftsChanged() {
|
||
updateSaveButton();
|
||
markAllDirtyRows();
|
||
}
|
||
|
||
// Window unload handler — call any in-flight drafts so the user
|
||
// doesn't lose typing on tab-close. The PUT uses keepalive:true so
|
||
// it survives navigation; that comes with a 64 KB body cap.
|
||
window.addEventListener('beforeunload', function (_ev) {
|
||
flushAllDrafts();
|
||
});
|
||
|
||
app.modules.save = {
|
||
saveRow: saveRow,
|
||
useMine: useMine,
|
||
reload: reload,
|
||
onSelectionChanged: onSelectionChanged,
|
||
onDraftsChanged: onDraftsChanged,
|
||
markAllDirtyRows: markAllDirtyRows,
|
||
updateSaveButton: updateSaveButton,
|
||
flushAll: flushAll,
|
||
dirtyCount: dirtyCount,
|
||
flushAllDrafts: flushAllDrafts,
|
||
};
|
||
})(window.tablesApp);
|
||
|
||
// row-ops.js — row-level operations (delete, future: duplicate,
|
||
// copy-to-table, etc.). Surfaced via a right-click context menu on
|
||
// table rows; the editor's selection state determines which row the
|
||
// action targets when the menu is invoked from the keyboard or from a
|
||
// future toolbar button.
|
||
//
|
||
// The shared context-menu primitive (window.zddc.menu) drives the
|
||
// rendering and keyboard behaviour. This module owns the menu spec
|
||
// and the action handlers.
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
function findRowById(rowId) {
|
||
const all = (app.state && app.state.rows) || [];
|
||
for (let i = 0; i < all.length; i++) {
|
||
const editor = app.modules.editor;
|
||
const key = editor ? editor.rowKey(all[i]) : (all[i].url || '');
|
||
if (key === rowId) return all[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function removeRowFromState(row) {
|
||
const all = app.state.rows || [];
|
||
const idx = all.indexOf(row);
|
||
if (idx >= 0) all.splice(idx, 1);
|
||
// Drop any drafts keyed on the row's url.
|
||
if (app.state.drafts && row.url) {
|
||
delete app.state.drafts[row.url];
|
||
}
|
||
}
|
||
|
||
function rowDisplayName(row) {
|
||
if (!row) return '(unknown)';
|
||
if (row.isNew) return '(unsaved new row)';
|
||
if (row.yamlUrl) {
|
||
const m = row.yamlUrl.match(/[^/]+$/);
|
||
if (m) return m[0];
|
||
}
|
||
return row.url || '(row)';
|
||
}
|
||
|
||
async function deleteRow(rowId) {
|
||
const row = findRowById(rowId);
|
||
if (!row) return { status: 'noop' };
|
||
if (row.editable === false) return { status: 'readonly' };
|
||
|
||
// Unsaved new row: just drop it. Nothing to call.
|
||
if (row.isNew) {
|
||
removeRowFromState(row);
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
return { status: 'ok-local' };
|
||
}
|
||
|
||
if (!row.yamlUrl) {
|
||
// file:// or fixture context — nothing to delete server-side.
|
||
removeRowFromState(row);
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
return { status: 'ok-local' };
|
||
}
|
||
|
||
const ok = window.confirm('Delete row "' + rowDisplayName(row) + '"?\n\nThis cannot be undone.');
|
||
if (!ok) return { status: 'cancelled' };
|
||
|
||
const headers = {};
|
||
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
|
||
let resp;
|
||
try {
|
||
resp = await fetch(row.yamlUrl, {
|
||
method: 'DELETE',
|
||
headers: headers,
|
||
credentials: 'same-origin'
|
||
});
|
||
} catch (err) {
|
||
window.alert('Delete failed: ' + (err && err.message ? err.message : err));
|
||
return { status: 'network-error', error: err };
|
||
}
|
||
if (resp.status === 200 || resp.status === 204) {
|
||
removeRowFromState(row);
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
return { status: 'ok' };
|
||
}
|
||
if (resp.status === 412) {
|
||
window.alert('Cannot delete: this row was changed since you loaded it. Reload to see the latest version.');
|
||
return { status: 'conflict' };
|
||
}
|
||
let body = '';
|
||
try { body = await resp.text(); } catch (_) { /* ignore */ }
|
||
window.alert('Delete failed (' + resp.status + '): ' + body);
|
||
return { status: 'http-error', code: resp.status };
|
||
}
|
||
|
||
// Returns the list of visible-row indices currently included in
|
||
// the editor's range selection. Empty when no range is active.
|
||
function rangeRowIndices() {
|
||
const range = app.state && app.state.range;
|
||
if (!range) return [];
|
||
const r0 = Math.min(range.anchor.row, range.focus.row);
|
||
const r1 = Math.max(range.anchor.row, range.focus.row);
|
||
const out = [];
|
||
for (let r = r0; r <= r1; r++) out.push(r);
|
||
return out;
|
||
}
|
||
|
||
// Map a visible-row index to its data-row-id (synthetic or real).
|
||
function rowIdAtIndex(idx) {
|
||
const trs = document.querySelectorAll('#table-root tbody > tr');
|
||
const tr = trs[idx];
|
||
return tr ? tr.getAttribute('data-row-id') : null;
|
||
}
|
||
|
||
async function deleteRows(rowIds) {
|
||
if (!rowIds || rowIds.length === 0) return { status: 'noop' };
|
||
if (rowIds.length === 1) return deleteRow(rowIds[0]);
|
||
const ok = window.confirm('Delete ' + rowIds.length + ' rows?\n\nThis cannot be undone.');
|
||
if (!ok) return { status: 'cancelled' };
|
||
// Walk back-to-front so removing by index from state.rows
|
||
// doesn't shift the indices of pending deletes.
|
||
let okCount = 0, failCount = 0;
|
||
for (let i = rowIds.length - 1; i >= 0; i--) {
|
||
const row = findRowById(rowIds[i]);
|
||
if (!row) continue;
|
||
if (row.isNew || !row.yamlUrl) {
|
||
removeRowFromState(row);
|
||
okCount++;
|
||
continue;
|
||
}
|
||
const headers = {};
|
||
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
|
||
try {
|
||
const resp = await fetch(row.yamlUrl, {
|
||
method: 'DELETE',
|
||
headers: headers,
|
||
credentials: 'same-origin'
|
||
});
|
||
if (resp.status === 200 || resp.status === 204) {
|
||
removeRowFromState(row);
|
||
okCount++;
|
||
} else {
|
||
failCount++;
|
||
}
|
||
} catch (_err) {
|
||
failCount++;
|
||
}
|
||
}
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
if (failCount > 0) {
|
||
window.alert('Deleted ' + okCount + ' row(s); ' + failCount + ' failed.');
|
||
}
|
||
return { status: 'ok', deleted: okCount, failed: failCount };
|
||
}
|
||
|
||
function buildRowMenu(ctx) {
|
||
const rangeRows = ctx.rangeRowIds || [];
|
||
const inRange = rangeRows.length > 1 && rangeRows.indexOf(ctx.rowId) !== -1;
|
||
const targets = inRange ? rangeRows : [ctx.rowId];
|
||
const items = [];
|
||
|
||
// Edit row — opens the schema-driven form-mode editor for
|
||
// this row. row.url is already the <id>.yaml.html form URL
|
||
// (the form handler unwraps virtual-view URLs server-side, so
|
||
// SSR + rollup rows route to their per-party canonical paths
|
||
// automatically). Disabled on multi-row range and unsaved
|
||
// draft rows (no backing file yet).
|
||
const singleRow = targets.length === 1 ? ctx.row : null;
|
||
const editUrl = singleRow && !singleRow.isNew && singleRow.url ? singleRow.url : null;
|
||
items.push({
|
||
label: 'Edit row',
|
||
icon: '✎',
|
||
disabled: !editUrl,
|
||
action: function () {
|
||
if (editUrl) window.location.href = editUrl;
|
||
}
|
||
});
|
||
|
||
items.push({ separator: true });
|
||
|
||
const label = targets.length > 1 ? 'Delete ' + targets.length + ' rows' : 'Delete row';
|
||
items.push({
|
||
label: label,
|
||
icon: '🗑',
|
||
danger: true,
|
||
disabled: !ctx.row || ctx.row.editable === false,
|
||
action: function () {
|
||
if (targets.length > 1) deleteRows(targets);
|
||
else deleteRow(targets[0]);
|
||
}
|
||
});
|
||
|
||
return items;
|
||
}
|
||
|
||
function onRowContext(ev) {
|
||
const tr = ev.target.closest('tr[data-row-id]');
|
||
if (!tr) return;
|
||
const rowId = tr.getAttribute('data-row-id');
|
||
const row = findRowById(rowId);
|
||
if (!row) return;
|
||
ev.preventDefault();
|
||
const menu = window.zddc && window.zddc.menu;
|
||
if (!menu || typeof menu.open !== 'function') return;
|
||
const rangeRowIds = rangeRowIndices().map(rowIdAtIndex).filter(Boolean);
|
||
menu.open({
|
||
x: ev.clientX,
|
||
y: ev.clientY,
|
||
items: buildRowMenu({ row: row, rowId: rowId, rangeRowIds: rangeRowIds }),
|
||
context: { row: row, rowId: rowId, rangeRowIds: rangeRowIds }
|
||
});
|
||
}
|
||
|
||
function attach() {
|
||
const tbody = document.querySelector('#table-root tbody');
|
||
if (!tbody) return;
|
||
tbody.addEventListener('contextmenu', onRowContext);
|
||
}
|
||
|
||
app.modules.rowOps = {
|
||
attach: attach,
|
||
deleteRow: deleteRow,
|
||
deleteRows: deleteRows,
|
||
};
|
||
})(window.tablesApp);
|
||
|
||
// clipboard.js — Phase 4 of editable-cell mode.
|
||
//
|
||
// Bidirectional clipboard interop with Excel / Google Sheets / any
|
||
// other spreadsheet that uses RFC-4180-ish TSV on the text/plain
|
||
// clipboard mime.
|
||
//
|
||
// Copy: when a single cell is selected, Ctrl/Cmd+C writes that
|
||
// cell's value as plain text. Range selection (Phase 5) extends
|
||
// this to a TSV rectangle.
|
||
//
|
||
// Paste: Ctrl/Cmd+V on the focused cell parses text/plain as TSV
|
||
// (tabs between columns, newlines between rows; embedded newlines
|
||
// or tabs are quoted with double-quotes; doubled "" escapes).
|
||
//
|
||
// - 1×1 clipboard into selected cell → writes that one cell.
|
||
// - N×M clipboard into selected cell → SPILLS from the anchor
|
||
// cell down/right to (anchor.row + N - 1, anchor.col + M - 1).
|
||
// Out-of-bounds cells are silently dropped (Excel convention).
|
||
//
|
||
// Each pasted cell goes through the same draft-buffer write path
|
||
// as a normal edit — the row-blur save trigger picks them up,
|
||
// and the per-cell schema-driven coercion (Phase 2) applies.
|
||
// Per-cell validation runs on the next save attempt; invalid
|
||
// cells get the red-corner mark.
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
function editor() { return app.modules.editor; }
|
||
|
||
// --- TSV parsing --------------------------------------------------
|
||
|
||
// parseTSV(text) → string[][]. Honors RFC-4180-ish quoting:
|
||
// - A field surrounded by " can contain tabs, newlines, and
|
||
// literal " characters escaped as "".
|
||
// - An unquoted field ends at the next tab, newline, or end.
|
||
// - Bare \r is treated as part of \r\n (Windows line endings).
|
||
function parseTSV(text) {
|
||
const rows = [];
|
||
let row = [];
|
||
let field = '';
|
||
let inQuotes = false;
|
||
const s = String(text == null ? '' : text);
|
||
|
||
for (let i = 0; i < s.length; i++) {
|
||
const ch = s[i];
|
||
if (inQuotes) {
|
||
if (ch === '"') {
|
||
if (s[i + 1] === '"') {
|
||
// Escaped quote inside a quoted field.
|
||
field += '"';
|
||
i++;
|
||
} else {
|
||
// End of quoted field.
|
||
inQuotes = false;
|
||
}
|
||
} else {
|
||
field += ch;
|
||
}
|
||
continue;
|
||
}
|
||
if (ch === '"' && field === '') {
|
||
// Open quote — only at start of field.
|
||
inQuotes = true;
|
||
continue;
|
||
}
|
||
if (ch === '\t') {
|
||
row.push(field);
|
||
field = '';
|
||
continue;
|
||
}
|
||
if (ch === '\n' || ch === '\r') {
|
||
// \r\n — consume the \n too.
|
||
if (ch === '\r' && s[i + 1] === '\n') i++;
|
||
row.push(field);
|
||
field = '';
|
||
rows.push(row);
|
||
row = [];
|
||
continue;
|
||
}
|
||
field += ch;
|
||
}
|
||
// Trailing field (no terminator).
|
||
if (field.length > 0 || row.length > 0) {
|
||
row.push(field);
|
||
rows.push(row);
|
||
}
|
||
// Excel often appends a trailing empty row from the final \n;
|
||
// drop one trailing all-empty row to match that convention.
|
||
if (rows.length > 0) {
|
||
const last = rows[rows.length - 1];
|
||
if (last.length === 1 && last[0] === '') rows.pop();
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
// formatTSV(grid) → string. Reverse of parseTSV. Quotes any
|
||
// field containing tab, newline, or double-quote.
|
||
function formatTSV(grid) {
|
||
const lines = [];
|
||
for (let r = 0; r < grid.length; r++) {
|
||
const row = grid[r];
|
||
const cells = [];
|
||
for (let c = 0; c < row.length; c++) {
|
||
cells.push(formatCell(row[c]));
|
||
}
|
||
lines.push(cells.join('\t'));
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function formatCell(v) {
|
||
const s = (v == null) ? '' : String(v);
|
||
if (/[\t\n\r"]/.test(s)) {
|
||
return '"' + s.replace(/"/g, '""') + '"';
|
||
}
|
||
return s;
|
||
}
|
||
|
||
// --- Apply paste --------------------------------------------------
|
||
|
||
function applyPaste(anchorRowIdx, anchorColIdx, grid) {
|
||
// grid is string[][]. Returns {applied: int, skipped: int, created: int}.
|
||
// When the paste extends past the last existing row, the
|
||
// add-row module creates new draft rows on the fly so an Excel
|
||
// copy lands as a complete data set, not a clipped one. Each
|
||
// new row will save on its own row-blur (POST to form-create).
|
||
const ed = editor();
|
||
const totalRows = visibleRowCount();
|
||
const cols = (app.context && app.context.columns) || [];
|
||
const totalCols = cols.length;
|
||
const addRow = app.modules.addRow;
|
||
let applied = 0, skipped = 0, created = 0;
|
||
|
||
for (let r = 0; r < grid.length; r++) {
|
||
const dstR = anchorRowIdx + r;
|
||
let row = null;
|
||
if (dstR < totalRows) {
|
||
row = rowDataAtIndex(dstR);
|
||
} else if (addRow && typeof addRow.createSilent === 'function') {
|
||
addRow.createSilent();
|
||
created++;
|
||
// After createSilent the new row is at the end of
|
||
// state.rows but the DOM hasn't repainted yet — pull
|
||
// straight from state.rows to address it.
|
||
const all = (app.state && app.state.rows) || [];
|
||
row = all[all.length - 1];
|
||
}
|
||
if (!row) { skipped += grid[r].length; continue; }
|
||
for (let c = 0; c < grid[r].length; c++) {
|
||
const dstC = anchorColIdx + c;
|
||
if (dstC >= totalCols) { skipped++; continue; }
|
||
const col = cols[dstC];
|
||
if (!col) { skipped++; continue; }
|
||
const newValue = coerceCell(grid[r][c], col, row);
|
||
ed.setDraft(ed.rowKey(row), col.field, newValue);
|
||
applied++;
|
||
}
|
||
}
|
||
return { applied: applied, skipped: skipped, created: created };
|
||
}
|
||
|
||
function visibleRowCount() {
|
||
return document.querySelectorAll('#table-root tbody > tr').length;
|
||
}
|
||
|
||
function rowDataAtIndex(r) {
|
||
const tr = document.querySelectorAll('#table-root tbody > tr')[r];
|
||
if (!tr) return null;
|
||
const rowId = tr.getAttribute('data-row-id');
|
||
if (rowId == null) return null;
|
||
const all = (app.state && app.state.rows) || [];
|
||
for (let i = 0; i < all.length; i++) {
|
||
if (editor().rowKey(all[i]) === rowId) return all[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function coerceCell(raw, col, _row) {
|
||
// Phase 2's editor coerces values typed into a number/checkbox/
|
||
// select widget. Pasted cells arrive as raw strings; coerce
|
||
// here so the draft holds the right JS type. Falls back to the
|
||
// raw string when coercion is ambiguous.
|
||
const fmt = col.format;
|
||
if (fmt === 'number' || fmt === 'integer' || isNumericSchema(col)) {
|
||
const n = Number(raw);
|
||
if (raw.trim() !== '' && !Number.isNaN(n)) return n;
|
||
}
|
||
if (isBooleanSchema(col)) {
|
||
const t = String(raw).trim().toLowerCase();
|
||
if (t === 'true' || t === 'yes' || t === '1') return true;
|
||
if (t === 'false' || t === 'no' || t === '0' || t === '') return false;
|
||
}
|
||
return raw;
|
||
}
|
||
|
||
function isNumericSchema(col) {
|
||
const s = propSchema(col);
|
||
return !!(s && (s.type === 'number' || s.type === 'integer'));
|
||
}
|
||
|
||
function isBooleanSchema(col) {
|
||
const s = propSchema(col);
|
||
return !!(s && s.type === 'boolean');
|
||
}
|
||
|
||
function propSchema(col) {
|
||
const ctx = app.context || {};
|
||
if (!ctx.rowSchema || !ctx.rowSchema.properties) return null;
|
||
return ctx.rowSchema.properties[col.field] || null;
|
||
}
|
||
|
||
// --- Event handlers ----------------------------------------------
|
||
|
||
function onPaste(ev) {
|
||
if (!app.state || !app.state.selected) return;
|
||
if (app.state.editing) return; // input owns its own paste
|
||
const text = ev.clipboardData && ev.clipboardData.getData('text/plain');
|
||
if (!text) return;
|
||
ev.preventDefault();
|
||
const grid = parseTSV(text);
|
||
if (!grid.length) return;
|
||
const { row: r, col: c } = app.state.selected;
|
||
const result = applyPaste(r, c, grid);
|
||
// Trigger a re-paint so draft values display.
|
||
if (typeof app.repaint === 'function') app.repaint();
|
||
let msg = 'Pasted ' + result.applied + ' cell' + plural(result.applied);
|
||
if (result.created > 0) {
|
||
msg += ' into ' + result.created + ' new row' + plural(result.created);
|
||
}
|
||
if (result.skipped > 0) {
|
||
msg += '; ' + result.skipped + ' dropped (out of bounds)';
|
||
}
|
||
if (result.created > 0 || result.skipped > 0) {
|
||
notifyToast(msg);
|
||
}
|
||
}
|
||
|
||
function onCopy(ev) {
|
||
if (!app.state || !app.state.selected) return;
|
||
if (app.state.editing) return; // input owns its own copy
|
||
const { row: r, col: c } = app.state.selected;
|
||
const row = rowDataAtIndex(r);
|
||
const cols = (app.context && app.context.columns) || [];
|
||
const col = cols[c];
|
||
if (!row || !col) return;
|
||
const value = editor().effectiveCellValue(row, col);
|
||
ev.preventDefault();
|
||
if (ev.clipboardData) {
|
||
ev.clipboardData.setData('text/plain', formatCell(value));
|
||
}
|
||
}
|
||
|
||
function plural(n) { return n === 1 ? '' : 's'; }
|
||
|
||
function notifyToast(msg) {
|
||
// Cheap toast: write to #table-status, auto-clear after 4s.
|
||
// Coexists with save.js's stale-row prompt — just don't fire
|
||
// if a prompt is currently up.
|
||
const el = document.getElementById('table-status');
|
||
if (!el) return;
|
||
if (el.classList.contains('table-status--prompt')) return;
|
||
el.textContent = msg;
|
||
el.hidden = false;
|
||
clearTimeout(notifyToast._t);
|
||
notifyToast._t = setTimeout(() => {
|
||
if (el.textContent === msg) {
|
||
el.hidden = true;
|
||
el.textContent = '';
|
||
}
|
||
}, 4000);
|
||
}
|
||
|
||
function attach() {
|
||
// Listen at the document level so paste events bubble from
|
||
// any cell with focus. No element-specific binding because
|
||
// Phase 1's roving tabindex moves focus around.
|
||
document.addEventListener('paste', onPaste);
|
||
document.addEventListener('copy', onCopy);
|
||
}
|
||
|
||
// Auto-wire on bootstrap. table-mode only — the dispatcher hides
|
||
// form-mode in this bundle, but be defensive if both modes ever
|
||
// coexist on a page (test fixtures): attach unconditionally; the
|
||
// handler bails when there's no selected cell.
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', attach, { once: true });
|
||
} else {
|
||
attach();
|
||
}
|
||
|
||
app.modules.clipboard = {
|
||
parseTSV: parseTSV,
|
||
formatTSV: formatTSV,
|
||
applyPaste: applyPaste,
|
||
};
|
||
})(window.tablesApp);
|
||
|
||
// export.js — CSV download of the current table view.
|
||
//
|
||
// Exports what the user sees: filter + sort applied, columns in the
|
||
// order declared by the spec. Values pass through util.formatCell so
|
||
// date / number / boolean cells match their on-screen rendering.
|
||
// RFC 4180 quoting (double-quote any cell with a comma, newline, or
|
||
// quote; escape inner quotes by doubling). UTF-8 BOM prepended so
|
||
// Excel detects the encoding without a manual import-wizard step.
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
function csvEscape(value) {
|
||
if (value == null) return '';
|
||
const str = String(value);
|
||
if (/[",\r\n]/.test(str)) {
|
||
return '"' + str.replace(/"/g, '""') + '"';
|
||
}
|
||
return str;
|
||
}
|
||
|
||
function buildCsv(rows, columns, util) {
|
||
const lines = [];
|
||
lines.push(columns.map(function (c) {
|
||
return csvEscape(c.title || c.field || '');
|
||
}).join(','));
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const row = rows[i];
|
||
const cells = columns.map(function (c) {
|
||
const raw = util.resolveField(row.data, c.field);
|
||
return csvEscape(util.formatCell(raw, c.format));
|
||
});
|
||
lines.push(cells.join(','));
|
||
}
|
||
return lines.join('\r\n') + '\r\n';
|
||
}
|
||
|
||
function suggestFilename() {
|
||
const titleEl = document.getElementById('table-title');
|
||
const raw = (titleEl && titleEl.textContent) ? titleEl.textContent : 'table';
|
||
const base = raw.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '') || 'table';
|
||
const stamp = new Date().toISOString().slice(0, 10);
|
||
return base + '-' + stamp + '.csv';
|
||
}
|
||
|
||
function download(csv, filename) {
|
||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||
}
|
||
|
||
function invoke() {
|
||
const ctx = app.context || {};
|
||
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||
if (columns.length === 0) {
|
||
return;
|
||
}
|
||
const state = app.state;
|
||
const util = app.modules.util;
|
||
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, util.resolveField);
|
||
const sorted = app.modules.sort.apply(filtered, state.sort, columns, util);
|
||
const csv = buildCsv(sorted, columns, util);
|
||
download(csv, suggestFilename());
|
||
}
|
||
|
||
app.modules.exportCsv = {
|
||
invoke: invoke,
|
||
buildCsv: buildCsv,
|
||
csvEscape: csvEscape
|
||
};
|
||
})(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;
|
||
const editor = app.modules.editor;
|
||
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'
|
||
});
|
||
const rowId = editor ? editor.rowKey(row) : (row.url || '');
|
||
if (editor) {
|
||
editor.attachToRow(tr, rowId);
|
||
}
|
||
for (let c = 0; c < columns.length; c++) {
|
||
const col = columns[c];
|
||
// Editor's draft buffer overrides the row's stored value
|
||
// until Phase 3 commits it. Falls back to row.data when
|
||
// no draft is present.
|
||
const value = editor
|
||
? editor.effectiveCellValue(row, col)
|
||
: util.resolveField(row.data, col.field);
|
||
const text = util.formatCell(value, col.format);
|
||
const td = util.h('td', { className: 'zddc-table__cell' }, text);
|
||
if (editor) {
|
||
editor.attachToCell(td, i, c);
|
||
}
|
||
tr.appendChild(td);
|
||
}
|
||
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');
|
||
const exportBtn = document.getElementById('table-export-csv');
|
||
const saveBtn = document.getElementById('table-save');
|
||
|
||
// Add-row button: appends a draft row inline. Save fires on
|
||
// row-blur, which POSTs to <dir>/form.html and swaps the
|
||
// synthetic row id for the server's response. The button shows
|
||
// whenever the page is a real table view (http(s) + a table
|
||
// context loaded with columns) — the test-fixture inline-context
|
||
// harness opens tables.html directly with no URL shape, so we
|
||
// gate on having a column list AND running over http(s).
|
||
// Save: explicit flush of every dirty row. The button is
|
||
// hidden until a draft exists; save.onDraftsChanged() (called
|
||
// from editor.setDraft / clearDraftField) toggles visibility +
|
||
// updates the count label. Backstop for the row-blur trigger,
|
||
// which only fires when the user navigates to a different
|
||
// ROW in the table — clicking outside the grid entirely never
|
||
// fired a save without this.
|
||
if (saveBtn) {
|
||
saveBtn.addEventListener('click', function () {
|
||
const save = app.modules.save;
|
||
if (save && typeof save.flushAll === 'function') {
|
||
save.flushAll();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Ctrl+S (Cmd+S on mac) flushes all dirty rows. Capturing
|
||
// phase so we beat the browser's "Save Page As" default.
|
||
window.addEventListener('keydown', function (ev) {
|
||
if ((ev.ctrlKey || ev.metaKey) && (ev.key === 's' || ev.key === 'S')) {
|
||
const save = app.modules.save;
|
||
if (save && typeof save.dirtyCount === 'function' && save.dirtyCount() > 0) {
|
||
ev.preventDefault();
|
||
save.flushAll();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Auto-save when focus leaves the grid entirely (the user
|
||
// clicked a header link, the URL bar, etc. without moving to
|
||
// another row first). focusout fires for cell-to-cell moves
|
||
// too — relatedTarget being outside #table-root distinguishes.
|
||
//
|
||
// Deferred to next tick (setTimeout 0): the editor's commit
|
||
// path tears down its input element and then refocuses the
|
||
// owning cell. The remove fires focusout BEFORE the refocus
|
||
// runs, with relatedTarget=null (body briefly), so the naive
|
||
// sync check would mis-detect a "left the grid" event and
|
||
// fire flushAll redundantly alongside the selection-change
|
||
// save. Checking document.activeElement on the next tick
|
||
// gives the refocus time to settle.
|
||
const tableRoot = document.getElementById('table-root');
|
||
if (tableRoot) {
|
||
tableRoot.addEventListener('focusout', function (ev) {
|
||
const next = ev.relatedTarget;
|
||
if (next && tableRoot.contains(next)) return;
|
||
setTimeout(function () {
|
||
if (tableRoot.contains(document.activeElement)) return;
|
||
const save = app.modules.save;
|
||
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
|
||
save.flushAll();
|
||
}
|
||
}, 0);
|
||
});
|
||
}
|
||
|
||
// Export CSV: client-side build of the current view (filtered +
|
||
// sorted columns + values). No server round-trip, no auth gate
|
||
// — the user already has the data on screen. Shown on every
|
||
// table that loaded with columns, regardless of HTTP/file://.
|
||
if (exportBtn) {
|
||
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
||
if (hasCols) {
|
||
exportBtn.hidden = false;
|
||
exportBtn.addEventListener('click', function () {
|
||
const exp = app.modules.exportCsv;
|
||
if (exp && typeof exp.invoke === 'function') {
|
||
exp.invoke();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
if (addRowBtn) {
|
||
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
||
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
|
||
// ctx.addable === false suppresses the affordance entirely.
|
||
// Used by project-rollup tables where the row's party
|
||
// affiliation is ambiguous (add at the per-party path).
|
||
const allowAdd = ctx.addable !== false;
|
||
if (onHttp && hasCols && allowAdd) {
|
||
addRowBtn.hidden = false;
|
||
addRowBtn.removeAttribute('href');
|
||
addRowBtn.setAttribute('role', 'button');
|
||
addRowBtn.setAttribute('tabindex', '0');
|
||
addRowBtn.style.cursor = 'pointer';
|
||
const handleAdd = function (ev) {
|
||
ev.preventDefault();
|
||
const addRow = app.modules.addRow;
|
||
if (addRow && typeof addRow.invoke === 'function') {
|
||
addRow.invoke();
|
||
}
|
||
};
|
||
addRowBtn.addEventListener('click', handleAdd);
|
||
addRowBtn.addEventListener('keydown', function (ev) {
|
||
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
|
||
});
|
||
|
||
// Permission gate: fetch the path-scoped verbs for the
|
||
// current directory and disable + Add row when the
|
||
// cascade denies create. Async — the button shows up
|
||
// optimistically and disables a tick later if the
|
||
// server says no, which is the same race window every
|
||
// path-scoped fetch has. Server still gates the POST,
|
||
// so the worst case is a 403 toast on click.
|
||
if (window.zddc && window.zddc.cap) {
|
||
window.zddc.cap.at(location.pathname).then(function (view) {
|
||
if (!view) return;
|
||
var verbs = view.path_verbs || '';
|
||
if (verbs.indexOf('c') === -1) {
|
||
addRowBtn.classList.add('is-disabled');
|
||
addRowBtn.setAttribute('aria-disabled', 'true');
|
||
addRowBtn.title = "You don't have create access in this folder.";
|
||
// Swallow clicks so the no-op feedback is the
|
||
// tooltip, not a 403 toast on submission.
|
||
addRowBtn.addEventListener('click', function (ev) {
|
||
if (addRowBtn.classList.contains('is-disabled')) {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
}
|
||
}, true);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
// Restore the editor's selection across re-paints so a sort
|
||
// or filter change doesn't dump the user out of the cell
|
||
// they were on. Selected coords clamp to the new bounds in
|
||
// setSelected; if the row vanished (filter excluded it),
|
||
// we land on the last valid cell instead of clearing.
|
||
const editor = app.modules.editor;
|
||
if (editor) {
|
||
editor.attachToTable();
|
||
if (state.selected) {
|
||
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
|
||
}
|
||
}
|
||
// Row context menu re-attaches each paint — renderBody wipes
|
||
// the tbody, taking listeners with it.
|
||
const rowOps = app.modules.rowOps;
|
||
if (rowOps && typeof rowOps.attach === 'function') {
|
||
rowOps.attach();
|
||
}
|
||
// Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in
|
||
// renderBody wiped them.
|
||
const save = app.modules.save;
|
||
if (save && typeof save.markAllDirtyRows === 'function') {
|
||
save.markAllDirtyRows();
|
||
}
|
||
// Refresh the Save button visibility + count after every
|
||
// paint — save flow may have settled drafts in the meantime.
|
||
if (save && typeof save.updateSaveButton === 'function') {
|
||
save.updateSaveButton();
|
||
}
|
||
}
|
||
|
||
// Public re-paint entry point so other modules (save.useMine /
|
||
// save.reload) can request a refresh after they mutate row state.
|
||
app.repaint = paint;
|
||
|
||
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']) || '';
|
||
// readonly is honored from either source: an explicit UI override
|
||
// (ui:readonly: true) or the schema's readOnly field. The latter
|
||
// is set by the server when augmenting from cascade-locked
|
||
// records: entries and for audit fields declared readOnly in the
|
||
// *.form.yaml.
|
||
const readonly = !!(schema.readOnly) || !!(ui && ui['ui:readonly']);
|
||
// x-labels: { code → label } turns a bare enum into a labeled
|
||
// dropdown ("ACM — Acme Inc" rather than just "ACM"). Injected
|
||
// by the server from the cascade's field_codes:codes map.
|
||
const labels = (schema && schema['x-labels']) || null;
|
||
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 codeStr = String(opt);
|
||
const radioId = id + '-' + idx;
|
||
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: codeStr });
|
||
if (value === opt) {
|
||
radio.checked = true;
|
||
}
|
||
if (readonly) {
|
||
radio.disabled = true;
|
||
}
|
||
let displayText = codeStr;
|
||
if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) {
|
||
displayText = codeStr + ' — ' + labels[codeStr];
|
||
}
|
||
const lbl = u.h('label', { for: radioId });
|
||
lbl.appendChild(radio);
|
||
lbl.appendChild(document.createTextNode(' ' + displayText));
|
||
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 codeStr = String(opt);
|
||
let displayText = codeStr;
|
||
if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) {
|
||
displayText = codeStr + ' — ' + labels[codeStr];
|
||
}
|
||
const o = u.h('option', { value: codeStr }, displayText);
|
||
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;
|
||
}
|
||
// Schema-driven HTML pattern attribute. Used as a UX hint
|
||
// only — authoritative validation runs server-side via the
|
||
// cascade's field_codes.
|
||
if (schema.pattern && input.tagName === 'INPUT') {
|
||
input.pattern = schema.pattern;
|
||
}
|
||
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);
|
||
}
|
||
|
||
// Cross-field mirror: a field with `ui:mirrorFrom: <sibling>`
|
||
// shows the live value of that sibling. Used by the project-
|
||
// rollup forms so the read-only `originator` reflects the
|
||
// selected Package (party) — the party folder is the
|
||
// originator's source of truth. Display-only: the server is
|
||
// still authoritative via the cascade's folder_fields.
|
||
for (let i = 0; i < ordered.length; i++) {
|
||
const name = ordered[i];
|
||
const mirrorFrom = ui && ui[name] && ui[name]['ui:mirrorFrom'];
|
||
if (!mirrorFrom || !children[name] || !children[mirrorFrom]) {
|
||
continue;
|
||
}
|
||
const targetInput = children[name].el.querySelector('input, select, textarea');
|
||
const sourceInput = children[mirrorFrom].el.querySelector('input, select, textarea');
|
||
if (!targetInput || !sourceInput) {
|
||
continue;
|
||
}
|
||
const sync = function () { targetInput.value = sourceInput.value; };
|
||
sourceInput.addEventListener('input', sync);
|
||
sourceInput.addEventListener('change', sync);
|
||
sync(); // initialize from any pre-filled party value
|
||
}
|
||
|
||
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-sm btn-secondary',
|
||
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-sm btn-secondary 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');
|
||
if (window.zddc && window.zddc.cap) {
|
||
window.zddc.cap.handleForbidden(res, {
|
||
context: 'Submit',
|
||
path: app.context.submitUrl
|
||
});
|
||
}
|
||
} 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';
|
||
|
||
// Friendly empty-state shown when the form is opened standalone
|
||
// (file:// or otherwise without a server-injected #form-context
|
||
// payload). The form renderer is always driven by the host —
|
||
// zddc-server's form handler injects schema+ui+data; the tool has
|
||
// no client-side picker because there's nothing it could pick from
|
||
// outside that contract.
|
||
function renderStandaloneWelcome(root) {
|
||
if (!root) return;
|
||
root.innerHTML = '';
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'form-welcome';
|
||
wrap.innerHTML = [
|
||
'<h2>ZDDC Form Renderer</h2>',
|
||
'<p>This tool renders a form spec injected by <code>zddc-server</code>',
|
||
' at <code><name>.form.html</code> URLs. There is no schema',
|
||
' to render here — most likely you opened the standalone HTML directly.</p>',
|
||
'<h3>To use it</h3>',
|
||
'<ol>',
|
||
'<li>Run <code>zddc-server</code> against an archive that contains a',
|
||
' <code><name>.form.yaml</code> spec.</li>',
|
||
'<li>Visit <code><path>/<name>.form.html</code> in the browser.</li>',
|
||
'</ol>',
|
||
'<p>See <a href="https://zddc.varasys.io/reference.html" target="_blank" rel="noopener">',
|
||
'zddc.varasys.io/reference.html</a> for the full ZDDC reference.</p>'
|
||
].join('');
|
||
root.appendChild(wrap);
|
||
|
||
const submitBtn = document.getElementById('submit-btn');
|
||
if (submitBtn) submitBtn.hidden = true;
|
||
}
|
||
|
||
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
|
||
);
|
||
} else if (root) {
|
||
// No schema — server-injected context is empty. Most common
|
||
// when the standalone form.html is opened from file:// without
|
||
// a host. Show a friendly explanation instead of a blank page.
|
||
renderStandaloneWelcome(root);
|
||
return;
|
||
}
|
||
|
||
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);
|
||
// Pre-flight gate: hide Submit when the cascade denies
|
||
// create at the submission directory. Server still
|
||
// enforces on POST — this just avoids dangling an
|
||
// affordance that would 403. Submission directory is the
|
||
// parent of submitUrl; fall back to the page URL when
|
||
// submitUrl is absent (file:// / no-context mode).
|
||
if (window.zddc && window.zddc.cap && app.context && app.context.submitUrl) {
|
||
const subUrl = app.context.submitUrl;
|
||
const dir = subUrl.replace(/\/[^\/]*$/, '/') || subUrl;
|
||
window.zddc.cap.at(dir).then(function (view) {
|
||
if (!view) return;
|
||
const verbs = view.path_verbs || '';
|
||
if (verbs.indexOf('c') === -1) {
|
||
submitBtn.hidden = true;
|
||
const status = document.getElementById('form-status');
|
||
if (status) {
|
||
status.textContent = "You don't have permission to submit here.";
|
||
status.hidden = false;
|
||
status.classList.add('is-error');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||
} else {
|
||
boot();
|
||
}
|
||
})(window.formApp);
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|