5908 lines
243 KiB
HTML
5908 lines
243 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>
|
||
/* ==========================================================================
|
||
ZDDC Shared Base — single source of truth for tokens and primitives
|
||
Included first by every tool's build.sh via ../shared/base.css
|
||
========================================================================== */
|
||
|
||
/* ── CSS custom properties ────────────────────────────────────────────────── */
|
||
:root {
|
||
/* Brand / accent (matches zddc.varasys.io website --accent) */
|
||
--primary: #2a5a8a;
|
||
--primary-hover: #1d4060;
|
||
--primary-active: #163352;
|
||
--primary-light: #e8f0f7;
|
||
|
||
/* Semantic colours */
|
||
--success: #28a745;
|
||
--warning: #d97706;
|
||
--danger: #dc3545;
|
||
--info: #17a2b8;
|
||
|
||
/* Backgrounds */
|
||
--bg: #ffffff;
|
||
--bg-secondary: #f8f9fa;
|
||
--bg-hover: #f0f4f8;
|
||
--bg-selected: var(--primary-light);
|
||
|
||
/* Text */
|
||
--text: #212529;
|
||
--text-muted: #6c757d;
|
||
--text-light: #ffffff;
|
||
|
||
/* Borders */
|
||
--border: #dee2e6;
|
||
--border-dark: #adb5bd;
|
||
|
||
/* Shape */
|
||
--radius: 4px;
|
||
|
||
/* Typography */
|
||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
|
||
}
|
||
|
||
/* ── Dark mode tokens ─────────────────────────────────────────────────────── */
|
||
/* Applied via: OS preference (auto) or [data-theme="dark"] on <html> */
|
||
/* The [data-theme="light"] selector locks light mode regardless of OS pref. */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) {
|
||
--primary: #4a90c4;
|
||
--primary-hover: #5ba3d9;
|
||
--primary-active: #6ab5e8;
|
||
--primary-light: #1a3550;
|
||
|
||
--bg: #1e1e1e;
|
||
--bg-secondary: #252526;
|
||
--bg-hover: #2d2d30;
|
||
--bg-selected: #1a3550;
|
||
|
||
--text: #d4d4d4;
|
||
--text-muted: #9d9d9d;
|
||
--text-light: #ffffff;
|
||
|
||
--border: #3e3e42;
|
||
--border-dark: #6e6e72;
|
||
}
|
||
}
|
||
|
||
/* Manual dark override — wins over media query */
|
||
[data-theme="dark"] {
|
||
--primary: #4a90c4;
|
||
--primary-hover: #5ba3d9;
|
||
--primary-active: #6ab5e8;
|
||
--primary-light: #1a3550;
|
||
|
||
--bg: #1e1e1e;
|
||
--bg-secondary: #252526;
|
||
--bg-hover: #2d2d30;
|
||
--bg-selected: #1a3550;
|
||
|
||
--text: #d4d4d4;
|
||
--text-muted: #9d9d9d;
|
||
--text-light: #ffffff;
|
||
|
||
--border: #3e3e42;
|
||
--border-dark: #6e6e72;
|
||
}
|
||
|
||
/* ── Reset ────────────────────────────────────────────────────────────────── */
|
||
*, *::before, *::after {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
/* ── Base document ────────────────────────────────────────────────────────── */
|
||
html, body {
|
||
height: 100%;
|
||
font-family: var(--font);
|
||
font-size: 16px;
|
||
line-height: 1.5;
|
||
color: var(--text);
|
||
background-color: var(--bg-secondary);
|
||
}
|
||
|
||
/* ── Typography ───────────────────────────────────────────────────────────── */
|
||
h1, h2, h3, h4, h5, h6 {
|
||
font-weight: 600;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
a {
|
||
color: var(--primary);
|
||
text-decoration: none;
|
||
}
|
||
|
||
a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* ── Utility ──────────────────────────────────────────────────────────────── */
|
||
.hidden {
|
||
display: none !important;
|
||
}
|
||
|
||
.truncate {
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
/* ── Scrollbars (webkit) ──────────────────────────────────────────────────── */
|
||
::-webkit-scrollbar {
|
||
width: 7px;
|
||
height: 7px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: #c1c1c1;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb:hover {
|
||
background: #a0a0a0;
|
||
}
|
||
|
||
/* ── Button primitive ─────────────────────────────────────────────────────── */
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
padding: 0.4rem 0.85rem;
|
||
font-family: var(--font);
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
line-height: 1.4;
|
||
text-align: center;
|
||
text-decoration: none;
|
||
white-space: nowrap;
|
||
vertical-align: middle;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--radius);
|
||
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, color 0.15s;
|
||
background: var(--bg-secondary);
|
||
color: var(--text);
|
||
}
|
||
|
||
.btn:disabled,
|
||
.btn[disabled] {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn:not(:disabled):hover {
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||
}
|
||
|
||
.btn:not(:disabled):active {
|
||
box-shadow: none;
|
||
}
|
||
|
||
/* Variants */
|
||
.btn-primary {
|
||
background: var(--primary);
|
||
color: var(--text-light);
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.btn-primary:not(:disabled):hover {
|
||
background: var(--primary-hover);
|
||
border-color: var(--primary-hover);
|
||
color: var(--text-light);
|
||
}
|
||
|
||
.btn-primary:not(:disabled):active {
|
||
background: var(--primary-active);
|
||
border-color: var(--primary-active);
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
border-color: var(--border);
|
||
}
|
||
|
||
.btn-secondary:not(:disabled):hover {
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
/* Subdued / de-emphasized variant.
|
||
Used on the "Add Local Directory" button when a tool is operating
|
||
in server (online) mode — the local-dir affordance is still
|
||
available but visually quieter, since the typical user already
|
||
has the directory loaded from the server. */
|
||
.btn.btn--subtle {
|
||
background: transparent;
|
||
color: var(--text-muted);
|
||
border-color: var(--border);
|
||
box-shadow: none;
|
||
font-weight: normal;
|
||
}
|
||
|
||
.btn.btn--subtle:not(:disabled):hover {
|
||
color: var(--text);
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.btn-success {
|
||
background: var(--success);
|
||
color: var(--text-light);
|
||
border-color: var(--success);
|
||
}
|
||
|
||
.btn-danger {
|
||
background: var(--danger);
|
||
color: var(--text-light);
|
||
border-color: var(--danger);
|
||
}
|
||
|
||
/* Sizes */
|
||
.btn-sm {
|
||
padding: 0.25rem 0.5rem;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.btn-lg {
|
||
padding: 0.6rem 1.4rem;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.btn-link {
|
||
background: transparent;
|
||
border-color: transparent;
|
||
color: var(--primary);
|
||
padding-left: 0;
|
||
padding-right: 0;
|
||
}
|
||
|
||
.btn-link:not(:disabled):hover {
|
||
text-decoration: underline;
|
||
box-shadow: none;
|
||
}
|
||
|
||
/* ── App header chrome ────────────────────────────────────────────────────── */
|
||
.app-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.35rem 1rem;
|
||
background: var(--bg-secondary);
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
/* Tool name inside the header */
|
||
.app-header__title {
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
letter-spacing: 0.01em;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Brand logo — sits left of the title in every tool's app-header.
|
||
Self-contained: the SVG provides its own dark blue rounded background,
|
||
so no extra wrapper styling is needed. */
|
||
.app-header__logo {
|
||
width: 26px;
|
||
height: 26px;
|
||
flex-shrink: 0;
|
||
display: block;
|
||
}
|
||
|
||
/* ── Build timestamp ──────────────────────────────────────────────────────── */
|
||
.build-timestamp {
|
||
font-size: 0.55rem;
|
||
color: var(--text-muted);
|
||
opacity: 0.7;
|
||
font-weight: 300;
|
||
white-space: nowrap;
|
||
padding-top: 0.15rem;
|
||
}
|
||
|
||
/* Title + timestamp stacked vertically on the left side of the header */
|
||
.header-title-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
line-height: 1;
|
||
}
|
||
|
||
/* ── Icon buttons (help, theme, refresh) ─────────────────────────────────── */
|
||
/* Square, centered — overrides the asymmetric text-button padding/line-height */
|
||
#help-btn,
|
||
#theme-btn,
|
||
#refreshHeaderBtn {
|
||
width: 2rem;
|
||
height: 2rem;
|
||
padding: 0;
|
||
line-height: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
/* 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);
|
||
}
|
||
|
||
/* 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; }
|
||
}
|
||
|
||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
||
Sits as a sibling immediately under .app-header (mounted by JS).
|
||
Rendered only in online mode when a project segment is in the URL. */
|
||
|
||
.zddc-stage-strip {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.3rem 1rem;
|
||
background: var(--bg);
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: 0.8rem;
|
||
line-height: 1.3;
|
||
flex-shrink: 0;
|
||
overflow-x: auto;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.zddc-stage-strip__project {
|
||
color: var(--text);
|
||
font-weight: 600;
|
||
margin-right: 0.15rem;
|
||
}
|
||
|
||
.zddc-stage-strip__divider,
|
||
.zddc-stage-strip__sep {
|
||
color: var(--text-muted);
|
||
user-select: none;
|
||
}
|
||
|
||
.zddc-stage-strip__divider {
|
||
margin-right: 0.35rem;
|
||
}
|
||
|
||
.zddc-stage {
|
||
color: var(--text-muted);
|
||
text-decoration: none;
|
||
padding: 0.1rem 0.25rem;
|
||
border-radius: var(--radius);
|
||
transition: color 0.15s, background 0.15s;
|
||
}
|
||
|
||
.zddc-stage:hover {
|
||
color: var(--text);
|
||
background: var(--bg-secondary);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.zddc-stage--active {
|
||
color: var(--primary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.zddc-stage--active:hover {
|
||
color: var(--primary);
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
|
||
|
||
.table-main {
|
||
padding: var(--spacing-md);
|
||
max-width: 100%;
|
||
}
|
||
|
||
.table-description {
|
||
margin: 0 0 var(--spacing-md);
|
||
color: var(--color-text-muted);
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.table-status {
|
||
margin: 0 0 var(--spacing-md);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
background: var(--color-bg-warning, #fff8e6);
|
||
border: 1px solid var(--color-border, #d6cfa3);
|
||
border-radius: var(--radius-sm, 4px);
|
||
}
|
||
|
||
.table-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: var(--spacing-md);
|
||
margin: 0 0 var(--spacing-sm);
|
||
}
|
||
|
||
.table-toolbar__left,
|
||
.table-toolbar__right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
#table-add-row {
|
||
text-decoration: none;
|
||
}
|
||
|
||
.table-rowcount {
|
||
color: var(--color-text-muted);
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.table-scroll {
|
||
overflow: auto;
|
||
max-height: calc(100vh - 200px);
|
||
border: 1px solid var(--color-border, #d8d8d8);
|
||
border-radius: var(--radius-sm, 4px);
|
||
}
|
||
|
||
.zddc-table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.zddc-table thead {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 2;
|
||
background: var(--color-bg-elevated, #f5f5f5);
|
||
}
|
||
|
||
.zddc-table__title-row .zddc-table__th {
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
text-align: left;
|
||
font-weight: 600;
|
||
border-bottom: 1px solid var(--color-border, #d8d8d8);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.zddc-table__title-row .zddc-table__th:hover {
|
||
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
|
||
}
|
||
|
||
.zddc-table__filter-row .zddc-table__filter-cell {
|
||
padding: 4px var(--spacing-sm);
|
||
border-bottom: 1px solid var(--color-border, #d8d8d8);
|
||
background: var(--color-bg-elevated, #f5f5f5);
|
||
}
|
||
|
||
.zddc-table__filter-text,
|
||
.zddc-table__filter-enum {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
padding: 2px 4px;
|
||
font-size: 0.85rem;
|
||
border: 1px solid var(--color-border, #d0d0d0);
|
||
border-radius: 3px;
|
||
background: var(--color-bg, #fff);
|
||
color: var(--color-text, #111);
|
||
}
|
||
|
||
.zddc-table__filter-enum {
|
||
min-height: 1.8em;
|
||
}
|
||
|
||
.zddc-table__row:nth-child(even) {
|
||
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
|
||
}
|
||
|
||
.zddc-table__row--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). */
|
||
.zddc-table__row--dirty td:first-child { box-shadow: inset 3px 0 0 var(--color-info, #4a90e2); }
|
||
.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.17-beta · 2026-05-10 · diamond-flame-kettle</span></span>
|
||
</div>
|
||
</div>
|
||
<div class="header-right">
|
||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Table mode: shown for /<dir>/table.html requests. -->
|
||
<main id="table-mode" class="table-main" hidden>
|
||
<div id="table-description" class="table-description" hidden></div>
|
||
<div id="table-status" class="table-status" hidden></div>
|
||
<div class="table-toolbar" id="table-toolbar">
|
||
<div class="table-toolbar__left">
|
||
<span id="table-rowcount" class="table-rowcount" aria-live="polite"></span>
|
||
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
|
||
</div>
|
||
<div class="table-toolbar__right">
|
||
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
||
</div>
|
||
</div>
|
||
<div class="table-scroll">
|
||
<table id="table-root" class="zddc-table" aria-describedby="table-description">
|
||
<thead></thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
<div id="table-empty" class="table-empty" hidden>No rows match the current filters.</div>
|
||
</main>
|
||
|
||
<!-- Form mode: shown for /<dir>/form.html and /<dir>/<id>.yaml.html
|
||
requests. Same bundle ships both modes so a row's "+ Add row"
|
||
and click-to-edit reuse the table tool's spec, validator, and
|
||
file-IO instead of duplicating them in a separate form HTML. -->
|
||
<main id="form-mode" class="form-main" hidden>
|
||
<div id="form-status" class="form-status" hidden></div>
|
||
<form id="form-root" class="form-root" novalidate></form>
|
||
<div class="form-actions">
|
||
<button type="button" id="submit-btn" class="btn btn-primary">Submit</button>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- Help Panel -->
|
||
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
||
<div class="help-panel__header">
|
||
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Table</h2>
|
||
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">×</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',
|
||
];
|
||
|
||
var STATUS_SET = {};
|
||
for (var _i = 0; _i < STATUSES.length; _i++) {
|
||
STATUS_SET[STATUSES[_i]] = true;
|
||
}
|
||
|
||
function isValidStatus(str) {
|
||
return !!STATUS_SET[str];
|
||
}
|
||
|
||
// ── Filename parsing ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Canonical file regex.
|
||
* Matches: TRACKING_REVISION (STATUS) - TITLE.EXT
|
||
*
|
||
* Tracking number: no underscores, no whitespace.
|
||
* Revision: no whitespace, no parentheses.
|
||
* Status: anything inside parentheses (validated separately).
|
||
* Title: everything up to the last dot.
|
||
* Extension: after the last dot (lowercased by parseFilename).
|
||
*/
|
||
var FILE_RE = /^([^_\s]+)_([^\s()_]+)\s*\(([^)]+)\)\s*-\s*(\S.*\S|\S)\.\s*([^\s.]+)$/;
|
||
|
||
/**
|
||
* Parse a ZDDC filename.
|
||
*
|
||
* @param {string} filename
|
||
* @returns {{ trackingNumber: string, revision: string, status: string,
|
||
* title: string, extension: string, valid: boolean } | null}
|
||
* null only if filename is falsy.
|
||
* `valid` is true when all fields matched the ZDDC pattern.
|
||
*/
|
||
function parseFilename(filename) {
|
||
if (!filename) { return null; }
|
||
|
||
var match = filename.match(FILE_RE);
|
||
|
||
if (!match) {
|
||
var lastDot = filename.lastIndexOf('.');
|
||
return {
|
||
trackingNumber: '',
|
||
revision: '',
|
||
status: '',
|
||
title: lastDot > 0 ? filename.substring(0, lastDot) : filename,
|
||
extension: lastDot > 0 ? filename.substring(lastDot + 1).toLowerCase() : '',
|
||
valid: false,
|
||
};
|
||
}
|
||
|
||
return {
|
||
trackingNumber: match[1].trim(),
|
||
revision: match[2].trim(),
|
||
status: match[3].trim(),
|
||
title: match[4].trim(),
|
||
extension: match[5].toLowerCase(),
|
||
valid: true,
|
||
};
|
||
}
|
||
|
||
// ── Folder name parsing ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Transmittal folder regex.
|
||
* Matches: YYYY-MM-DD_TRACKING (STATUS) - TITLE
|
||
*/
|
||
var FOLDER_RE = /^(\d{4}-\d{2}-\d{2})_([^_\s(]+)\s*\(([^)]+)\)\s*-\s*(.+)$/;
|
||
|
||
/**
|
||
* Parse a ZDDC transmittal folder name.
|
||
*
|
||
* @param {string} foldername
|
||
* @returns {{ date: string, trackingNumber: string, status: string,
|
||
* title: string, valid: boolean } | null}
|
||
* null only if foldername is falsy.
|
||
*/
|
||
function parseFolder(foldername) {
|
||
if (!foldername) { return null; }
|
||
|
||
var match = foldername.match(FOLDER_RE);
|
||
|
||
if (!match) {
|
||
return {
|
||
date: '',
|
||
trackingNumber: '',
|
||
status: '',
|
||
title: foldername,
|
||
valid: false,
|
||
};
|
||
}
|
||
|
||
return {
|
||
date: match[1],
|
||
trackingNumber: match[2].trim(),
|
||
status: match[3].trim(),
|
||
title: match[4].trim(),
|
||
valid: true,
|
||
};
|
||
}
|
||
|
||
// ── Revision parsing ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Modifier sub-regex: +LETTER DIGITS e.g. +C1, +B2, +N1, +Q1
|
||
* The draft prefix (~) may appear inside the modifier: A+~C1
|
||
*/
|
||
var MODIFIER_RE = /^\+(~?)([A-Za-z])(\d+)$/;
|
||
|
||
/**
|
||
* Parse a ZDDC revision string.
|
||
*
|
||
* Revision grammar:
|
||
* revision = ['~'] base ['+' ['~'] modifier_letter modifier_number]
|
||
* base = letter(s) | digit(s) | date(YYYY-MM-DD)
|
||
* modifier = letter + digits e.g. C1, B2, N1, Q1
|
||
*
|
||
* @param {string} revision
|
||
* @returns {{
|
||
* base: string,
|
||
* modifier: string, full modifier string e.g. '+C1', '' if none
|
||
* modifierType: string, modifier letter e.g. 'C', '' if none
|
||
* modifierNumber: number, modifier number e.g. 1, 0 if none
|
||
* modifierIsDraft: boolean,
|
||
* isDraft: boolean, true if base revision starts with ~
|
||
* full: string, original input
|
||
* }}
|
||
*/
|
||
function parseRevision(revision) {
|
||
var raw = (revision || '').toString();
|
||
|
||
// Split on '+' to separate base from optional modifier
|
||
var plusIdx = raw.indexOf('+');
|
||
var basePart = plusIdx === -1 ? raw : raw.substring(0, plusIdx);
|
||
var modifierPart = plusIdx === -1 ? '' : raw.substring(plusIdx);
|
||
|
||
// Draft flag on the base part
|
||
var isDraft = basePart.startsWith('~');
|
||
var base = isDraft ? basePart.substring(1) : basePart;
|
||
|
||
// Parse modifier
|
||
var modifier = '';
|
||
var modifierType = '';
|
||
var modifierNumber = 0;
|
||
var modifierIsDraft = false;
|
||
|
||
if (modifierPart) {
|
||
var mMatch = modifierPart.match(MODIFIER_RE);
|
||
if (mMatch) {
|
||
modifierIsDraft = mMatch[1] === '~';
|
||
modifierType = mMatch[2].toUpperCase();
|
||
modifierNumber = parseInt(mMatch[3], 10);
|
||
modifier = modifierPart;
|
||
} else {
|
||
// Unrecognised modifier — preserve as-is
|
||
modifier = modifierPart;
|
||
}
|
||
}
|
||
|
||
return {
|
||
base: base,
|
||
modifier: modifier,
|
||
modifierType: modifierType,
|
||
modifierNumber: modifierNumber,
|
||
modifierIsDraft: modifierIsDraft,
|
||
isDraft: isDraft,
|
||
full: raw,
|
||
};
|
||
}
|
||
|
||
// ── Revision comparison ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Classify a base revision string into a sort tier:
|
||
* 0 = date (YYYY-MM-DD)
|
||
* 1 = letter(s) A, B, AA …
|
||
* 2 = number(s) 0, 1, 2, 1.5 …
|
||
* 3 = other
|
||
*/
|
||
function _baseTier(base) {
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(base)) { return 0; }
|
||
if (/^[A-Za-z]+$/.test(base)) { return 1; }
|
||
if (/^\d+(\.\d+)?$/.test(base)) { return 2; }
|
||
return 3;
|
||
}
|
||
|
||
/**
|
||
* Compare two base revision strings.
|
||
* Sort order: dates < letters < numbers < other.
|
||
*/
|
||
function _compareBase(a, b) {
|
||
var ta = _baseTier(a);
|
||
var tb = _baseTier(b);
|
||
if (ta !== tb) { return ta - tb; }
|
||
|
||
if (ta === 0) { return a < b ? -1 : a > b ? 1 : 0; } // date lexicographic = chronological
|
||
if (ta === 1) { return a.toUpperCase() < b.toUpperCase() ? -1 : a.toUpperCase() > b.toUpperCase() ? 1 : 0; }
|
||
if (ta === 2) { return parseFloat(a) - parseFloat(b); }
|
||
return a.localeCompare(b);
|
||
}
|
||
|
||
/**
|
||
* Compare two ZDDC revision strings for sort ordering.
|
||
*
|
||
* Canonical order (ascending = older → newer):
|
||
* ~A < A < A+B1 < A+C1 < A+~C2 < A+C2 < A+N1 < A+Q1
|
||
* < ~B < B < … < 0 < 1 < 2
|
||
*
|
||
* Rules:
|
||
* 1. Compare base revisions first (dates < letters < numbers).
|
||
* 2. For equal bases, draft (isDraft=true) comes before final.
|
||
* 3. For equal base+draft, no-modifier < has-modifier.
|
||
* 4. For equal base+draft+modifier presence:
|
||
* a. modifier draft comes before modifier final (modifierIsDraft).
|
||
* b. Sort modifier by type letter then by number.
|
||
*
|
||
* @param {string} a
|
||
* @param {string} b
|
||
* @returns {number} negative if a < b, 0 if equal, positive if a > b
|
||
*/
|
||
function compareRevisions(a, b) {
|
||
var pa = parseRevision(a);
|
||
var pb = parseRevision(b);
|
||
|
||
// 1. Base revision
|
||
var baseCmp = _compareBase(pa.base, pb.base);
|
||
if (baseCmp !== 0) { return baseCmp; }
|
||
|
||
// 2. Draft before final (for same base)
|
||
if (pa.isDraft !== pb.isDraft) { return pa.isDraft ? -1 : 1; }
|
||
|
||
// 3. No modifier before any modifier
|
||
var aHasMod = pa.modifier !== '';
|
||
var bHasMod = pb.modifier !== '';
|
||
if (aHasMod !== bHasMod) { return aHasMod ? 1 : -1; }
|
||
|
||
if (!aHasMod) { return 0; } // both have no modifier
|
||
|
||
// 4. Compare modifiers: type → number → draft (draft is a tie-breaker only)
|
||
// 4a. Modifier type letter (B < C < N < Q …)
|
||
if (pa.modifierType !== pb.modifierType) {
|
||
return pa.modifierType < pb.modifierType ? -1 : 1;
|
||
}
|
||
|
||
// 4b. Modifier number (1 < 2 …)
|
||
if (pa.modifierNumber !== pb.modifierNumber) {
|
||
return pa.modifierNumber - pb.modifierNumber;
|
||
}
|
||
|
||
// 4c. Draft of a modifier comes before the final modifier (same type+number)
|
||
if (pa.modifierIsDraft !== pb.modifierIsDraft) {
|
||
return pa.modifierIsDraft ? -1 : 1;
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
// ── Filename / folder formatting ─────────────────────────────────────────
|
||
|
||
/**
|
||
* Build a ZDDC filename from its components.
|
||
*
|
||
* @param {{ trackingNumber: string, revision: string, status: string,
|
||
* title: string, extension: string }} parts
|
||
* @returns {string} e.g. "123456-EL-SPC-2623_A (IFR) - Specification.pdf"
|
||
*/
|
||
function formatFilename(parts) {
|
||
var tn = (parts.trackingNumber || '').trim();
|
||
var rev = (parts.revision || '').trim();
|
||
var st = (parts.status || '').trim();
|
||
var ttl = (parts.title || '').trim();
|
||
var ext = (parts.extension || '').replace(/^\./, '');
|
||
|
||
if (!tn || !rev || !st || !ttl) { return ''; }
|
||
|
||
var name = tn + '_' + rev + ' (' + st + ') - ' + ttl;
|
||
return ext ? name + '.' + ext : name;
|
||
}
|
||
|
||
/**
|
||
* Build a ZDDC transmittal folder name from its components.
|
||
*
|
||
* @param {{ date: string, trackingNumber: string, status: string,
|
||
* title: string }} parts
|
||
* @returns {string} e.g. "2025-10-31_123456-EM-SUB-0001 (IFR) - Title"
|
||
*/
|
||
function formatFolder(parts) {
|
||
var dt = (parts.date || '').trim();
|
||
var tn = (parts.trackingNumber || '').trim();
|
||
var st = (parts.status || '').trim();
|
||
var ttl = (parts.title || '').trim();
|
||
|
||
if (!dt || !tn || !st || !ttl) { return ''; }
|
||
|
||
return dt + '_' + tn + ' (' + st + ') - ' + ttl;
|
||
}
|
||
|
||
// ── Filename / extension splitting ───────────────────────────────────────
|
||
|
||
/**
|
||
* Split a filename into its base name and extension (no leading dot).
|
||
* Treats leading dot ('.gitignore') as no extension.
|
||
*
|
||
* @param {string} filename
|
||
* @returns {{ name: string, extension: string }}
|
||
*/
|
||
function splitExtension(filename) {
|
||
if (!filename) { return { name: '', extension: '' }; }
|
||
var lastDot = filename.lastIndexOf('.');
|
||
if (lastDot <= 0) { return { name: filename, extension: '' }; }
|
||
return {
|
||
name: filename.substring(0, lastDot),
|
||
extension: filename.substring(lastDot + 1).toLowerCase(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Join a base name and extension. Tolerant of either form ('pdf' or '.pdf').
|
||
* Returns just the name when extension is empty.
|
||
*/
|
||
function joinExtension(name, extension) {
|
||
var ext = (extension || '').replace(/^\./, '');
|
||
return ext ? name + '.' + ext : name;
|
||
}
|
||
|
||
// ── Public API ───────────────────────────────────────────────────────────
|
||
|
||
root.zddc = {
|
||
STATUSES: STATUSES,
|
||
isValidStatus: isValidStatus,
|
||
parseFilename: parseFilename,
|
||
parseFolder: parseFolder,
|
||
parseRevision: parseRevision,
|
||
formatFilename: formatFilename,
|
||
formatFolder: formatFolder,
|
||
compareRevisions: compareRevisions,
|
||
splitExtension: splitExtension,
|
||
joinExtension: joinExtension,
|
||
};
|
||
|
||
}(typeof window !== 'undefined' ? window : this));
|
||
|
||
// shared/zddc-source.js — source abstraction for tools that handle
|
||
// directory trees (classifier, mdedit, transmittal, browse, archive).
|
||
//
|
||
// Two backends:
|
||
//
|
||
// 1. Local — wraps a real FileSystemDirectoryHandle from the
|
||
// File System Access API. Reads + writes go through the
|
||
// FS Access API directly.
|
||
//
|
||
// 2. HTTP — talks to zddc-server's directory listing JSON
|
||
// (Accept: application/json) for reads and the file API
|
||
// (PUT/DELETE/POST X-ZDDC-Op) for writes. Implements a
|
||
// polyfill of the FS Access API surface area the tools
|
||
// use (kind, name, values(), getFileHandle, getDirectoryHandle,
|
||
// removeEntry, getFile, createWritable, queryPermission /
|
||
// requestPermission) so existing code works unchanged.
|
||
//
|
||
// The polyfill makes auto-load possible: when zddc-server serves
|
||
// a tool at /<dir>/<tool>.html, the tool detects HTTP mode at
|
||
// startup, builds an HttpDirectoryHandle for the tool's containing
|
||
// directory, and hands it to the existing openDirectory(handle)
|
||
// flow without ever showing the file picker.
|
||
//
|
||
// Renames inside a tool today are typically done as
|
||
// "write new + remove old". With HTTP-backed handles this becomes
|
||
// PUT + DELETE — non-atomic. Tools that prefer the atomic server
|
||
// MOVE should call window.zddc.source.moveFile(srcUrl, dstUrl)
|
||
// directly instead of going through the polyfill.
|
||
(function () {
|
||
'use strict';
|
||
|
||
if (!window.zddc) window.zddc = {};
|
||
var FA = window.FileSystemDirectoryHandle || null;
|
||
|
||
// -----------------------------------------------------------------
|
||
// HTTP file API helpers
|
||
// -----------------------------------------------------------------
|
||
|
||
function joinUrl(base, name, isDir) {
|
||
if (!base.endsWith('/')) base = base + '/';
|
||
return base + encodeURIComponent(name) + (isDir ? '/' : '');
|
||
}
|
||
|
||
// Server returns directory entries with a trailing "/" on names.
|
||
// Strip it for the FS Access API name surface.
|
||
function stripSlash(name) {
|
||
return name.endsWith('/') ? name.slice(0, -1) : name;
|
||
}
|
||
|
||
async function httpListing(url) {
|
||
var resp = await fetch(url, {
|
||
headers: { 'Accept': 'application/json' },
|
||
credentials: 'same-origin'
|
||
});
|
||
if (!resp.ok) {
|
||
var err = new Error('listing ' + url + ': HTTP ' + resp.status);
|
||
err.status = resp.status;
|
||
throw err;
|
||
}
|
||
var data = await resp.json();
|
||
if (!Array.isArray(data)) {
|
||
throw new Error('listing ' + url + ': non-array body');
|
||
}
|
||
return data;
|
||
}
|
||
|
||
async function httpExists(url) {
|
||
try {
|
||
var r = await fetch(url, { method: 'HEAD', credentials: 'same-origin' });
|
||
return r.ok;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------
|
||
// HttpFileHandle — FileSystemFileHandle polyfill
|
||
// -----------------------------------------------------------------
|
||
|
||
function makeFile(blob, name, modTime) {
|
||
return new File([blob], name, {
|
||
type: blob.type,
|
||
lastModified: modTime ? modTime.getTime() : Date.now()
|
||
});
|
||
}
|
||
|
||
function HttpFileHandle(url, name, size, modTime) {
|
||
this.kind = 'file';
|
||
this.name = name;
|
||
this._url = url;
|
||
this._size = size || 0;
|
||
this._modTime = modTime || null;
|
||
this._etag = null;
|
||
}
|
||
HttpFileHandle.prototype.getFile = async function () {
|
||
var resp = await fetch(this._url, { credentials: 'same-origin' });
|
||
if (!resp.ok) {
|
||
throw new Error('GET ' + this._url + ': ' + resp.status);
|
||
}
|
||
var etag = resp.headers.get('ETag');
|
||
if (etag) this._etag = etag.replace(/"/g, '');
|
||
var lm = resp.headers.get('Last-Modified');
|
||
var modTime = lm ? new Date(lm) : this._modTime;
|
||
var blob = await resp.blob();
|
||
return makeFile(blob, this.name, modTime);
|
||
};
|
||
HttpFileHandle.prototype.createWritable = async function () {
|
||
var chunks = [];
|
||
var handle = this;
|
||
return {
|
||
async write(data) {
|
||
if (data == null) return;
|
||
if (typeof data === 'object' && data && 'type' in data && data.type === 'write') {
|
||
chunks.push(data.data);
|
||
return;
|
||
}
|
||
if (typeof data === 'object' && data && 'type' in data) {
|
||
// seek/truncate not supported by HTTP backend
|
||
throw new Error('HttpFileHandle write op not supported: ' + data.type);
|
||
}
|
||
chunks.push(data);
|
||
},
|
||
async close() {
|
||
var blob = new Blob(chunks);
|
||
var resp = await fetch(handle._url, {
|
||
method: 'PUT',
|
||
body: blob,
|
||
credentials: 'same-origin'
|
||
});
|
||
if (!resp.ok) {
|
||
var body = '';
|
||
try { body = await resp.text(); } catch (_) { /* ignore */ }
|
||
throw new Error('PUT ' + handle._url + ': ' + resp.status + ' ' + body);
|
||
}
|
||
var et = resp.headers.get('ETag');
|
||
if (et) handle._etag = et.replace(/"/g, '');
|
||
handle._size = blob.size;
|
||
},
|
||
async abort() { chunks = []; }
|
||
};
|
||
};
|
||
HttpFileHandle.prototype.queryPermission = async function () { return 'granted'; };
|
||
HttpFileHandle.prototype.requestPermission = async function () { return 'granted'; };
|
||
HttpFileHandle.prototype.isHttp = true;
|
||
HttpFileHandle.prototype.url = function () { return this._url; };
|
||
|
||
// -----------------------------------------------------------------
|
||
// HttpDirectoryHandle — FileSystemDirectoryHandle polyfill
|
||
// -----------------------------------------------------------------
|
||
|
||
function HttpDirectoryHandle(url, name) {
|
||
this.kind = 'directory';
|
||
if (!url.endsWith('/')) url = url + '/';
|
||
this._url = url;
|
||
this.name = name || guessNameFromUrl(url);
|
||
}
|
||
function guessNameFromUrl(url) {
|
||
var u = url.replace(/\/+$/, '');
|
||
var slash = u.lastIndexOf('/');
|
||
return slash >= 0 ? decodeURIComponent(u.substring(slash + 1)) : u;
|
||
}
|
||
HttpDirectoryHandle.prototype.values = function () {
|
||
var url = this._url;
|
||
return (async function* () {
|
||
var entries;
|
||
try {
|
||
entries = await httpListing(url);
|
||
} catch (e) {
|
||
return;
|
||
}
|
||
for (var i = 0; i < entries.length; i++) {
|
||
var e = entries[i];
|
||
var rawName = stripSlash(e.name);
|
||
// 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
|
||
// -----------------------------------------------------------------
|
||
|
||
// Strip a trailing tool .html (e.g. classifier.html) from a path
|
||
// to land on the "directory the tool was opened in".
|
||
function pathToDir(pathname) {
|
||
if (!pathname) return '/';
|
||
if (pathname.endsWith('/')) return pathname;
|
||
var slash = pathname.lastIndexOf('/');
|
||
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
|
||
}
|
||
|
||
// Probe the server-mode root for the current page. Returns:
|
||
//
|
||
// { handle: HttpDirectoryHandle, status: 200 } — server reachable, listing returned
|
||
// { handle: null, status: 403 } — server reachable but listing forbidden
|
||
// { handle: null, status: 0 } — not http(s), or server unreachable / non-JSON
|
||
//
|
||
// Tools that auto-load on startup distinguish 403 (show "no
|
||
// permission to list this directory" message) from 0 (fall back
|
||
// to local-mode welcome screen).
|
||
//
|
||
// Tool init pattern:
|
||
// if (location.protocol !== 'file:') {
|
||
// const r = await zddc.source.detectServerRoot();
|
||
// if (r.handle) await openDirectory(r.handle);
|
||
// else if (r.status === 403) showNoPermissionMessage();
|
||
// else showWelcome();
|
||
// } else { showWelcome(); }
|
||
async function detectServerRoot() {
|
||
if (typeof location === 'undefined') {
|
||
return { handle: null, status: 0 };
|
||
}
|
||
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
||
return { handle: null, status: 0 };
|
||
}
|
||
var dirPath = pathToDir(location.pathname);
|
||
var url = location.origin + dirPath;
|
||
try {
|
||
await httpListing(url);
|
||
} catch (e) {
|
||
if (e && e.status === 403) {
|
||
return { handle: null, status: 403 };
|
||
}
|
||
return { handle: null, status: 0 };
|
||
}
|
||
return {
|
||
handle: new HttpDirectoryHandle(url, guessNameFromUrl(url)),
|
||
status: 200,
|
||
};
|
||
}
|
||
|
||
// Atomic file move. Path arguments are absolute URL paths
|
||
// (starting with /). Honors the file API's POST /op=move
|
||
// contract. Returns the new ETag.
|
||
async function moveFile(srcUrlPath, dstUrlPath, opts) {
|
||
opts = opts || {};
|
||
var headers = {
|
||
'X-ZDDC-Op': 'move',
|
||
'X-ZDDC-Destination': dstUrlPath
|
||
};
|
||
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch;
|
||
var resp = await fetch(srcUrlPath, {
|
||
method: 'POST',
|
||
headers: headers,
|
||
credentials: 'same-origin'
|
||
});
|
||
if (!resp.ok) {
|
||
var body = '';
|
||
try { body = await resp.text(); } catch (_) { /* ignore */ }
|
||
throw new Error('move ' + srcUrlPath + ' → ' + dstUrlPath + ': ' + resp.status + ' ' + body);
|
||
}
|
||
var et = resp.headers.get('ETag');
|
||
return et ? et.replace(/"/g, '') : null;
|
||
}
|
||
|
||
// Detect at construction time whether a directory handle is the
|
||
// HTTP polyfill or a real FS Access API handle. Useful for tools
|
||
// that want to take the optimized path (e.g. atomic moveFile)
|
||
// when in HTTP mode rather than the FS-API copy+remove fallback.
|
||
function isHttpHandle(handle) {
|
||
return !!(handle && handle.isHttp === true);
|
||
}
|
||
|
||
window.zddc.source = {
|
||
HttpDirectoryHandle: HttpDirectoryHandle,
|
||
HttpFileHandle: HttpFileHandle,
|
||
detectServerRoot: detectServerRoot,
|
||
moveFile: moveFile,
|
||
isHttpHandle: isHttpHandle,
|
||
// Lower-level helpers exposed for tools that want to call the
|
||
// server directly without going through the polyfill.
|
||
httpListing: httpListing,
|
||
joinUrl: joinUrl,
|
||
stripSlash: stripSlash
|
||
};
|
||
})();
|
||
|
||
/**
|
||
* ZDDC shared theme toggle — light / dark / auto.
|
||
* Persists choice to localStorage under 'zddc-theme'.
|
||
* Works with all four tools regardless of their module pattern.
|
||
* Expects: #theme-btn in the DOM (optional — skips gracefully if absent).
|
||
*
|
||
* Theme cycle: auto → light → dark → auto …
|
||
* 'auto' honours the OS prefers-color-scheme media query (CSS handles it).
|
||
* 'light' sets data-theme="light" on <html> (overrides dark media query).
|
||
* 'dark' sets data-theme="dark" on <html>.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
var STORAGE_KEY = 'zddc-theme';
|
||
var THEMES = ['auto', 'light', 'dark'];
|
||
|
||
var LABELS = {
|
||
auto: '◐',
|
||
light: '☀',
|
||
dark: '☾'
|
||
};
|
||
|
||
var TITLES = {
|
||
auto: 'Theme: auto (follows OS)',
|
||
light: 'Theme: light',
|
||
dark: 'Theme: dark'
|
||
};
|
||
|
||
function load() {
|
||
var stored = localStorage.getItem(STORAGE_KEY);
|
||
return THEMES.indexOf(stored) !== -1 ? stored : 'auto';
|
||
}
|
||
|
||
function apply(theme) {
|
||
if (theme === 'dark') {
|
||
document.documentElement.setAttribute('data-theme', 'dark');
|
||
} else if (theme === 'light') {
|
||
document.documentElement.setAttribute('data-theme', 'light');
|
||
} else {
|
||
document.documentElement.removeAttribute('data-theme');
|
||
}
|
||
}
|
||
|
||
function save(theme) {
|
||
try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
|
||
}
|
||
|
||
function updateButton(btn, theme) {
|
||
btn.textContent = LABELS[theme];
|
||
btn.title = TITLES[theme];
|
||
btn.setAttribute('aria-label', TITLES[theme]);
|
||
}
|
||
|
||
function next(theme) {
|
||
return THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
|
||
}
|
||
|
||
function init() {
|
||
var current = load();
|
||
apply(current);
|
||
|
||
var btn = document.getElementById('theme-btn');
|
||
if (!btn) { return; }
|
||
|
||
updateButton(btn, current);
|
||
|
||
btn.addEventListener('click', function () {
|
||
current = next(current);
|
||
apply(current);
|
||
save(current);
|
||
updateButton(btn, current);
|
||
});
|
||
}
|
||
|
||
/* Apply theme immediately (before DOM ready) to avoid flash */
|
||
apply(load());
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
}());
|
||
|
||
// 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;
|
||
})();
|
||
|
||
// shared/nav.js — lateral navigation strip across the four canonical
|
||
// project stages (archive · working · staging · reviewing). Renders
|
||
// only when:
|
||
// 1. location.protocol is http: or https: (online — file:// has no
|
||
// project structure to navigate within), AND
|
||
// 2. a project segment can be detected from location.pathname (the
|
||
// first path segment, when it isn't a tool HTML file).
|
||
//
|
||
// The strip is inserted as a sibling of <header class="app-header">
|
||
// on DOMContentLoaded — no template changes required. Each tool just
|
||
// needs ../shared/nav.{js,css} in its build.sh.
|
||
//
|
||
// Stage URLs follow the canonical workflow folders documented at
|
||
// zddc.varasys.io/reference.html#transmittal-workflow:
|
||
// archive → <project>/archive.html (archive tool, project-root mode)
|
||
// working → <project>/working/ (directory listing → mdedit auto-serves)
|
||
// staging → <project>/staging/ (directory listing → transmittal auto-serves)
|
||
// reviewing → <project>/reviewing/ (directory listing)
|
||
//
|
||
// If a deployment doesn't have one of these folders the link will 404 —
|
||
// the strip is convention-driven, not probed. Operators on non-standard
|
||
// layouts can override by setting window.zddc.nav.disabled = true before
|
||
// DOMContentLoaded.
|
||
(function () {
|
||
'use strict';
|
||
|
||
if (!window.zddc) window.zddc = {};
|
||
if (window.zddc.nav) return; // already loaded
|
||
|
||
var STAGES = [
|
||
{ key: 'archive', label: 'Archive', target: 'archive.html' },
|
||
{ key: 'working', label: 'Working', target: 'working/' },
|
||
{ key: 'staging', label: 'Staging', target: 'staging/' },
|
||
{ key: 'reviewing', label: 'Reviewing', target: 'reviewing/' },
|
||
];
|
||
|
||
function projectSegment(pathname) {
|
||
var parts = pathname.split('/').filter(Boolean);
|
||
if (parts.length === 0) return null;
|
||
var first = parts[0];
|
||
// At deployment root (e.g. /archive.html?projects=A,B or
|
||
// /index.html) the first segment is a tool HTML — no single
|
||
// project to scope the strip to.
|
||
if (first.indexOf('.') !== -1) return null;
|
||
return first;
|
||
}
|
||
|
||
function currentStage(pathname) {
|
||
var parts = pathname.split('/').filter(Boolean);
|
||
if (parts.length < 2) return null;
|
||
var second = parts[1];
|
||
// <project>/working/... | staging/... | reviewing/... | archive/...
|
||
for (var i = 0; i < STAGES.length; i++) {
|
||
if (second === STAGES[i].key) return STAGES[i].key;
|
||
}
|
||
// <project>/archive.html → still the archive stage
|
||
if (second === 'archive.html') return 'archive';
|
||
return null;
|
||
}
|
||
|
||
function shouldRender() {
|
||
if (typeof location === 'undefined') return false;
|
||
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
|
||
if (window.zddc.nav && window.zddc.nav.disabled) return false;
|
||
return projectSegment(location.pathname) !== null;
|
||
}
|
||
|
||
function buildStrip(project, active) {
|
||
var nav = document.createElement('nav');
|
||
nav.className = 'zddc-stage-strip';
|
||
nav.setAttribute('aria-label', 'Project stage');
|
||
|
||
var label = document.createElement('span');
|
||
label.className = 'zddc-stage-strip__project';
|
||
label.textContent = project;
|
||
nav.appendChild(label);
|
||
|
||
var sep0 = document.createElement('span');
|
||
sep0.className = 'zddc-stage-strip__divider';
|
||
sep0.setAttribute('aria-hidden', 'true');
|
||
sep0.textContent = '/';
|
||
nav.appendChild(sep0);
|
||
|
||
for (var i = 0; i < STAGES.length; i++) {
|
||
var s = STAGES[i];
|
||
var a = document.createElement('a');
|
||
a.className = 'zddc-stage';
|
||
a.href = '/' + encodeURIComponent(project) + '/' + s.target;
|
||
a.textContent = s.label;
|
||
if (s.key === active) {
|
||
a.classList.add('zddc-stage--active');
|
||
a.setAttribute('aria-current', 'page');
|
||
}
|
||
nav.appendChild(a);
|
||
|
||
if (i < STAGES.length - 1) {
|
||
var sep = document.createElement('span');
|
||
sep.className = 'zddc-stage-strip__sep';
|
||
sep.setAttribute('aria-hidden', 'true');
|
||
sep.textContent = '·';
|
||
nav.appendChild(sep);
|
||
}
|
||
}
|
||
|
||
return nav;
|
||
}
|
||
|
||
function mount() {
|
||
if (!shouldRender()) return;
|
||
var header = document.querySelector('.app-header');
|
||
if (!header) return;
|
||
// Don't double-mount if a tool's main.js calls us a second time.
|
||
if (header.previousElementSibling &&
|
||
header.previousElementSibling.classList &&
|
||
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
||
return;
|
||
}
|
||
var project = projectSegment(location.pathname);
|
||
var active = currentStage(location.pathname);
|
||
var strip = buildStrip(project, active);
|
||
// Mount ABOVE the header — the strip is project-level chrome
|
||
// (where in the project), the header is tool-level chrome (which
|
||
// tool, theme, help). Reading order matches outer-to-inner scope.
|
||
header.parentNode.insertBefore(strip, header);
|
||
}
|
||
|
||
// Expose for tests + opt-out.
|
||
window.zddc.nav = {
|
||
mount: mount,
|
||
// Internals visible for unit tests; do not call from tools.
|
||
_projectSegment: projectSegment,
|
||
_currentStage: currentStage,
|
||
_stages: STAGES,
|
||
// Set to true before DOMContentLoaded to suppress mounting on
|
||
// deployments where the canonical folder layout doesn't apply.
|
||
disabled: false,
|
||
};
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
||
} else {
|
||
mount();
|
||
}
|
||
})();
|
||
|
||
// 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();
|
||
}
|
||
}());
|
||
|
||
// 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,
|
||
rowSchema: rowSchema,
|
||
rows: rows
|
||
};
|
||
}
|
||
|
||
function tableNameFromUrl(pathname) {
|
||
// /<dir>/.../<rowsdir>/table.html → name is the rows-dir's
|
||
// basename.
|
||
const m = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
|
||
return m ? m[1] : null;
|
||
}
|
||
|
||
function stripDotSlash(p) {
|
||
let out = String(p || '');
|
||
if (out.startsWith('./')) out = out.slice(2);
|
||
if (out.startsWith('/')) out = out.slice(1);
|
||
if (out.endsWith('/')) out = out.slice(0, -1);
|
||
return out;
|
||
}
|
||
|
||
async function readYaml(dir, relPath) {
|
||
const fileHandle = await resolveFile(dir, relPath);
|
||
const file = await fileHandle.getFile();
|
||
const text = await file.text();
|
||
if (!window.jsyaml) {
|
||
throw new Error('js-yaml not loaded');
|
||
}
|
||
return window.jsyaml.load(text);
|
||
}
|
||
|
||
// Walk a "/"-separated relative path under dir, returning the
|
||
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
|
||
async function resolveFile(dir, relPath) {
|
||
const parts = relPath.split('/').filter(Boolean);
|
||
if (parts.length === 0) {
|
||
throw new Error('Empty file path');
|
||
}
|
||
const fileName = parts.pop();
|
||
let cur = dir;
|
||
for (let i = 0; i < parts.length; i++) {
|
||
cur = await cur.getDirectoryHandle(parts[i]);
|
||
}
|
||
return cur.getFileHandle(fileName);
|
||
}
|
||
|
||
async function resolveDirectory(dir, relPath) {
|
||
const parts = relPath.split('/').filter(Boolean);
|
||
let cur = dir;
|
||
for (let i = 0; i < parts.length; i++) {
|
||
cur = await cur.getDirectoryHandle(parts[i]);
|
||
}
|
||
return cur;
|
||
}
|
||
|
||
async function readRows(rowsDir, _rowsRel, _tableName) {
|
||
const rows = [];
|
||
for await (const entry of rowsDir.values()) {
|
||
if (entry.kind !== 'file') continue;
|
||
if (!entry.name.endsWith('.yaml')) continue;
|
||
// Skip the spec and the row-edit form — they live alongside
|
||
// the rows but aren't rows themselves.
|
||
if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
|
||
try {
|
||
const 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. Page is at /<dir>/table.html; row file
|
||
// lives at /<dir>/<basename>.yaml; form re-edit URL is
|
||
// /<dir>/<basename>.yaml.html — same directory.
|
||
function rowEditUrl(rowFileName) {
|
||
const pageDir = location.pathname.replace(/\/table\.html$/, '/');
|
||
return pageDir + rowFileName + '.html';
|
||
}
|
||
|
||
app.modules.context = { load: load };
|
||
})(window.tablesApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const util = {};
|
||
|
||
util.h = function (tag, attrs) {
|
||
const el = document.createElement(tag);
|
||
if (attrs) {
|
||
for (const k of Object.keys(attrs)) {
|
||
const v = attrs[k];
|
||
if (v == null || v === false) {
|
||
continue;
|
||
}
|
||
if (k === 'className') {
|
||
el.className = v;
|
||
} else if (k.length > 2 && k.slice(0, 2) === 'on' && typeof v === 'function') {
|
||
el.addEventListener(k.slice(2).toLowerCase(), v);
|
||
} else if (v === true) {
|
||
el.setAttribute(k, '');
|
||
} else {
|
||
el.setAttribute(k, v);
|
||
}
|
||
}
|
||
}
|
||
for (let i = 2; i < arguments.length; i++) {
|
||
const c = arguments[i];
|
||
if (c == null || c === false) {
|
||
continue;
|
||
}
|
||
if (typeof c === 'string' || typeof c === 'number') {
|
||
el.appendChild(document.createTextNode(String(c)));
|
||
} else {
|
||
el.appendChild(c);
|
||
}
|
||
}
|
||
return el;
|
||
};
|
||
|
||
// Resolve a column's `field` against a row data object.
|
||
// - "" or "/" → the whole object
|
||
// - "/foo/bar" → JSON Pointer (RFC 6901) lookup
|
||
// - "foo" → top-level key
|
||
util.resolveField = function (data, field) {
|
||
if (data == null) {
|
||
return undefined;
|
||
}
|
||
if (!field || field === '/') {
|
||
return data;
|
||
}
|
||
if (field.charAt(0) !== '/') {
|
||
return data[field];
|
||
}
|
||
const segments = field.split('/').slice(1).map(function (s) {
|
||
return s.replace(/~1/g, '/').replace(/~0/g, '~');
|
||
});
|
||
let cur = data;
|
||
for (let i = 0; i < segments.length; i++) {
|
||
if (cur == null) {
|
||
return undefined;
|
||
}
|
||
if (Array.isArray(cur)) {
|
||
const idx = parseInt(segments[i], 10);
|
||
if (Number.isNaN(idx)) {
|
||
return undefined;
|
||
}
|
||
cur = cur[idx];
|
||
} else if (typeof cur === 'object') {
|
||
cur = cur[segments[i]];
|
||
} else {
|
||
return undefined;
|
||
}
|
||
}
|
||
return cur;
|
||
};
|
||
|
||
// Format a raw cell value per column's `format` hint.
|
||
util.formatCell = function (value, format) {
|
||
if (value == null || value === '') {
|
||
return '';
|
||
}
|
||
if (format === 'date') {
|
||
const d = new Date(value);
|
||
if (!isNaN(d.getTime())) {
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
return String(value);
|
||
}
|
||
if (format === 'datetime') {
|
||
const d = new Date(value);
|
||
if (!isNaN(d.getTime())) {
|
||
return d.toLocaleString();
|
||
}
|
||
return String(value);
|
||
}
|
||
if (format === 'number') {
|
||
const n = Number(value);
|
||
if (Number.isFinite(n)) {
|
||
return n.toLocaleString();
|
||
}
|
||
return String(value);
|
||
}
|
||
if (format === 'bool' || typeof value === 'boolean') {
|
||
return value ? '✓' : '';
|
||
}
|
||
if (typeof value === 'object') {
|
||
try {
|
||
return JSON.stringify(value);
|
||
} catch (e) {
|
||
return String(value);
|
||
}
|
||
}
|
||
return String(value);
|
||
};
|
||
|
||
// Compare two cell values for sorting. null/undefined sort last.
|
||
// Numbers compared numerically, dates compared as Date, otherwise string compare.
|
||
util.compareCells = function (a, b, format) {
|
||
const aMissing = a == null || a === '';
|
||
const bMissing = b == null || b === '';
|
||
if (aMissing && bMissing) {
|
||
return 0;
|
||
}
|
||
if (aMissing) {
|
||
return 1;
|
||
}
|
||
if (bMissing) {
|
||
return -1;
|
||
}
|
||
if (format === 'date' || format === 'datetime') {
|
||
const da = new Date(a).getTime();
|
||
const db = new Date(b).getTime();
|
||
if (!isNaN(da) && !isNaN(db)) {
|
||
return da - db;
|
||
}
|
||
}
|
||
if (format === 'number' || (typeof a === 'number' && typeof b === 'number')) {
|
||
const na = Number(a);
|
||
const nb = Number(b);
|
||
if (Number.isFinite(na) && Number.isFinite(nb)) {
|
||
return na - nb;
|
||
}
|
||
}
|
||
const sa = String(a).toLowerCase();
|
||
const sb = String(b).toLowerCase();
|
||
if (sa < sb) return -1;
|
||
if (sa > sb) return 1;
|
||
return 0;
|
||
};
|
||
|
||
app.modules.util = util;
|
||
})(window.tablesApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
// A filter is per-column and has one of two shapes:
|
||
// - free-text: { kind: 'contains', value: '<string>' }
|
||
// - enum: { kind: 'enum', value: ['<choice>', ...] }
|
||
// An empty value (empty string or empty array) matches everything.
|
||
//
|
||
// The render layer only emits the free-text shape; enum is kept here
|
||
// for back-compat with any inline-context test fixtures that seed
|
||
// filter state directly. defaultFilterFor always returns text.
|
||
|
||
function isEnumColumn(col) {
|
||
return Array.isArray(col.enum) && col.enum.length > 0;
|
||
}
|
||
|
||
function defaultFilterFor(_col) {
|
||
return { kind: 'contains', value: '' };
|
||
}
|
||
|
||
function rowMatches(filter, cellValue) {
|
||
if (filter.kind === 'enum') {
|
||
if (!Array.isArray(filter.value) || filter.value.length === 0) {
|
||
return true;
|
||
}
|
||
const s = cellValue == null ? '' : String(cellValue);
|
||
return filter.value.indexOf(s) !== -1;
|
||
}
|
||
// contains
|
||
if (!filter.value) {
|
||
return true;
|
||
}
|
||
const needle = String(filter.value).toLowerCase();
|
||
const hay = cellValue == null ? '' : String(cellValue).toLowerCase();
|
||
return hay.indexOf(needle) !== -1;
|
||
}
|
||
|
||
function isEmpty(filter) {
|
||
if (filter.kind === 'enum') {
|
||
return !Array.isArray(filter.value) || filter.value.length === 0;
|
||
}
|
||
return !filter.value;
|
||
}
|
||
|
||
function apply(rows, columns, filterMap, resolveField) {
|
||
return rows.filter(function (row) {
|
||
for (let i = 0; i < columns.length; i++) {
|
||
const col = columns[i];
|
||
const filter = filterMap[col.field];
|
||
if (!filter || isEmpty(filter)) {
|
||
continue;
|
||
}
|
||
const cellValue = resolveField(row.data, col.field);
|
||
if (!rowMatches(filter, cellValue)) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
app.modules.filters = {
|
||
defaultFilterFor: defaultFilterFor,
|
||
isEnumColumn: isEnumColumn,
|
||
isEmpty: isEmpty,
|
||
apply: apply
|
||
};
|
||
})(window.tablesApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
// Sort state is an ordered list of {field, dir} keys; the first is
|
||
// primary, additional keys break ties.
|
||
|
||
function defaultsFromContext(ctx) {
|
||
const defaults = ctx.defaults || {};
|
||
if (Array.isArray(defaults.sort) && defaults.sort.length > 0) {
|
||
return defaults.sort.slice();
|
||
}
|
||
// Fall back to any column with `sort:` set.
|
||
const fromCols = (ctx.columns || []).filter(function (c) { return c.sort; });
|
||
if (fromCols.length > 0) {
|
||
return fromCols.map(function (c) {
|
||
const dir = c.sort === 'desc' ? 'desc' : 'asc';
|
||
return { field: c.field, dir: dir };
|
||
});
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function findColumn(columns, field) {
|
||
for (let i = 0; i < columns.length; i++) {
|
||
if (columns[i].field === field) {
|
||
return columns[i];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Click handler for a header: cycle the sort state for `field`.
|
||
// - Not currently a sort key → add as primary, asc
|
||
// - Currently primary asc → flip to desc
|
||
// - Currently primary desc → remove
|
||
// - Currently secondary → promote to primary, asc
|
||
// Shift-click is meant for additional accumulation but we keep the
|
||
// single-click semantics simple; advanced multi-sort can be a
|
||
// follow-up.
|
||
function cycle(state, field, multi) {
|
||
const idx = state.findIndex(function (s) { return s.field === field; });
|
||
if (multi) {
|
||
if (idx === -1) {
|
||
return state.concat([{ field: field, dir: 'asc' }]);
|
||
}
|
||
const cur = state[idx];
|
||
if (cur.dir === 'asc') {
|
||
const next = state.slice();
|
||
next[idx] = { field: field, dir: 'desc' };
|
||
return next;
|
||
}
|
||
return state.slice(0, idx).concat(state.slice(idx + 1));
|
||
}
|
||
if (idx === -1) {
|
||
return [{ field: field, dir: 'asc' }];
|
||
}
|
||
if (idx === 0) {
|
||
const cur = state[0];
|
||
if (cur.dir === 'asc') {
|
||
return [{ field: field, dir: 'desc' }];
|
||
}
|
||
return [];
|
||
}
|
||
return [{ field: field, dir: 'asc' }];
|
||
}
|
||
|
||
function apply(rows, sortState, columns, util) {
|
||
if (!sortState || sortState.length === 0) {
|
||
return rows;
|
||
}
|
||
const out = rows.slice();
|
||
out.sort(function (a, b) {
|
||
for (let i = 0; i < sortState.length; i++) {
|
||
const key = sortState[i];
|
||
const col = findColumn(columns, key.field);
|
||
const fmt = col ? col.format : '';
|
||
const av = util.resolveField(a.data, key.field);
|
||
const bv = util.resolveField(b.data, key.field);
|
||
const cmp = util.compareCells(av, bv, fmt);
|
||
if (cmp !== 0) {
|
||
return key.dir === 'desc' ? -cmp : cmp;
|
||
}
|
||
}
|
||
return 0;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function indicator(sortState, field) {
|
||
for (let i = 0; i < sortState.length; i++) {
|
||
if (sortState[i].field === field) {
|
||
const arrow = sortState[i].dir === 'desc' ? ' ▼' : ' ▲';
|
||
if (sortState.length > 1) {
|
||
return arrow + (i + 1);
|
||
}
|
||
return arrow;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
app.modules.sort = {
|
||
defaultsFromContext: defaultsFromContext,
|
||
cycle: cycle,
|
||
apply: apply,
|
||
indicator: indicator
|
||
};
|
||
})(window.tablesApp);
|
||
|
||
// 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;
|
||
}
|
||
|
||
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];
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
const propSchema = propertySchemaFor(col);
|
||
|
||
// 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);
|
||
|
||
// 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.
|
||
return Object.assign({}, data || {}, drafts || {});
|
||
}
|
||
|
||
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 || !drafts || Object.keys(drafts).length === 0) {
|
||
return { status: 'noop' };
|
||
}
|
||
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, '');
|
||
row.data = 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();
|
||
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');
|
||
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 };
|
||
}
|
||
|
||
// Other status — generic error.
|
||
console.warn('[tables] save 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(() => {});
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
markAllDirtyRows: markAllDirtyRows,
|
||
flushAllDrafts: flushAllDrafts,
|
||
};
|
||
})(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}.
|
||
const ed = editor();
|
||
const totalRows = visibleRowCount();
|
||
const cols = (app.context && app.context.columns) || [];
|
||
const totalCols = cols.length;
|
||
let applied = 0, skipped = 0;
|
||
|
||
for (let r = 0; r < grid.length; r++) {
|
||
const dstR = anchorRowIdx + r;
|
||
if (dstR >= totalRows) { skipped += grid[r].length; continue; }
|
||
const row = rowDataAtIndex(dstR);
|
||
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 };
|
||
}
|
||
|
||
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();
|
||
if (result.skipped > 0) {
|
||
notifyToast(
|
||
'Pasted ' + result.applied + ' cell' + plural(result.applied) +
|
||
'; ' + result.skipped + ' dropped (out of bounds)'
|
||
);
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
(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');
|
||
|
||
// Add-row button: link to <name>.form.html, the form-system's
|
||
// empty-form URL for this table's row schema. POST creates a
|
||
// new submission and the server redirects to the row's edit
|
||
// URL. Hidden when we can't derive a table name from the
|
||
// pathname (e.g. inline-context test harness opening tables.html
|
||
// directly without a *.table.html URL).
|
||
if (addRowBtn) {
|
||
// Page is at <dir>/table.html; the row-creation form is at
|
||
// <dir>/form.html — same directory, just swap the basename.
|
||
if (/\/table\.html$/.test(location.pathname || '')) {
|
||
addRowBtn.href = 'form.html';
|
||
addRowBtn.hidden = false;
|
||
}
|
||
}
|
||
|
||
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
||
|
||
const state = app.state;
|
||
state.rows = allRows;
|
||
state.sort = app.modules.sort.defaultsFromContext(ctx);
|
||
state.filter = {};
|
||
|
||
// Seed default filters from context.defaults.filter (per-column).
|
||
if (ctx.defaults && ctx.defaults.filter && typeof ctx.defaults.filter === 'object') {
|
||
for (let i = 0; i < columns.length; i++) {
|
||
const col = columns[i];
|
||
const seeded = ctx.defaults.filter[col.field];
|
||
if (seeded == null) {
|
||
continue;
|
||
}
|
||
// Filter UI is uniformly text-contains. If the spec
|
||
// seeds an array (legacy enum-style), coerce to a
|
||
// comma-joined contains string — partial match on any
|
||
// listed value still narrows the table sensibly.
|
||
const seedStr = Array.isArray(seeded) ? seeded.join(',') : String(seeded);
|
||
state.filter[col.field] = { kind: 'contains', value: seedStr };
|
||
}
|
||
}
|
||
|
||
function anyFilterActive() {
|
||
const filters = app.modules.filters;
|
||
const keys = Object.keys(state.filter);
|
||
for (let i = 0; i < keys.length; i++) {
|
||
if (!filters.isEmpty(state.filter[keys[i]])) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function paint() {
|
||
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, app.modules.util.resolveField);
|
||
const sorted = app.modules.sort.apply(filtered, state.sort, columns, app.modules.util);
|
||
app.modules.render.header(theadEl, columns, state.sort, state.filter, onHeaderClick, onFilterChange);
|
||
app.modules.render.body(tbodyEl, sorted, columns);
|
||
app.modules.render.rowCount(countEl, sorted.length, state.rows.length);
|
||
if (emptyEl) {
|
||
emptyEl.hidden = sorted.length > 0 || state.rows.length === 0;
|
||
}
|
||
if (clearBtn) {
|
||
clearBtn.hidden = !anyFilterActive();
|
||
}
|
||
// 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 });
|
||
}
|
||
}
|
||
// 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();
|
||
}
|
||
}
|
||
|
||
// 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']) || '';
|
||
const readonly = !!(ui && ui['ui:readonly']);
|
||
const autofocus = !!(ui && ui['ui:autofocus']);
|
||
|
||
let input;
|
||
let read;
|
||
|
||
const t = schema.type;
|
||
|
||
if (t === 'boolean') {
|
||
// Render boolean as a single checkbox with an inline label, suppressing
|
||
// the standard label-above layout for cleaner UX.
|
||
const cb = u.h('input', { type: 'checkbox', id: id });
|
||
if (value === true) {
|
||
cb.checked = true;
|
||
}
|
||
if (readonly) {
|
||
cb.disabled = true;
|
||
}
|
||
const wrap = u.h('div', { className: 'form-field form-field--boolean' });
|
||
const inlineLabel = u.h('label', { for: id, className: 'form-field__checkbox-inline' });
|
||
inlineLabel.appendChild(cb);
|
||
inlineLabel.appendChild(document.createTextNode(' '));
|
||
inlineLabel.appendChild(document.createTextNode(label || ''));
|
||
if (required) {
|
||
inlineLabel.appendChild(u.h('span', { className: 'required-mark' }, '*'));
|
||
}
|
||
wrap.appendChild(inlineLabel);
|
||
if (description) {
|
||
wrap.appendChild(u.h('div', { className: 'form-field__description' }, description));
|
||
}
|
||
if (help) {
|
||
wrap.appendChild(u.h('div', { className: 'form-field__help' }, help));
|
||
}
|
||
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
|
||
wrap.appendChild(errEl);
|
||
|
||
return widgetObject(wrap, errEl, path, function () {
|
||
return cb.checked;
|
||
});
|
||
}
|
||
|
||
if (Array.isArray(schema.enum)) {
|
||
const opts = schema.enum;
|
||
if (widget === 'radio') {
|
||
input = u.h('div', { className: 'form-field__radio-group' });
|
||
opts.forEach(function (opt, idx) {
|
||
const radioId = id + '-' + idx;
|
||
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: String(opt) });
|
||
if (value === opt) {
|
||
radio.checked = true;
|
||
}
|
||
if (readonly) {
|
||
radio.disabled = true;
|
||
}
|
||
const lbl = u.h('label', { for: radioId });
|
||
lbl.appendChild(radio);
|
||
lbl.appendChild(document.createTextNode(' ' + String(opt)));
|
||
input.appendChild(lbl);
|
||
});
|
||
read = function () {
|
||
const checked = input.querySelector('input[type="radio"]:checked');
|
||
return checked ? coerceEnum(checked.value, opts) : undefined;
|
||
};
|
||
} else {
|
||
input = u.h('select', { id: id, className: 'form-field__select' });
|
||
if (!required) {
|
||
input.appendChild(u.h('option', { value: '' }, '— select —'));
|
||
}
|
||
opts.forEach(function (opt) {
|
||
const o = u.h('option', { value: String(opt) }, String(opt));
|
||
if (value === opt) {
|
||
o.selected = true;
|
||
}
|
||
input.appendChild(o);
|
||
});
|
||
if (readonly) {
|
||
input.disabled = true;
|
||
}
|
||
read = function () {
|
||
if (input.value === '') {
|
||
return undefined;
|
||
}
|
||
return coerceEnum(input.value, opts);
|
||
};
|
||
}
|
||
} else if (t === 'number' || t === 'integer') {
|
||
input = u.h('input', {
|
||
type: 'number',
|
||
id: id,
|
||
className: 'form-field__input',
|
||
step: t === 'integer' ? '1' : 'any'
|
||
});
|
||
if (placeholder) {
|
||
input.placeholder = placeholder;
|
||
}
|
||
if (value != null) {
|
||
input.value = String(value);
|
||
}
|
||
if (readonly) {
|
||
input.readOnly = true;
|
||
}
|
||
if (autofocus) {
|
||
input.autofocus = true;
|
||
}
|
||
read = function () {
|
||
const v = input.value.trim();
|
||
if (v === '') {
|
||
return undefined;
|
||
}
|
||
const n = Number(v);
|
||
// If the user typed something non-numeric, return the raw string and
|
||
// let server validation produce a friendly error.
|
||
return Number.isFinite(n) ? n : v;
|
||
};
|
||
} else {
|
||
// Default: string-shaped input.
|
||
const fmt = schema.format;
|
||
if (widget === 'textarea') {
|
||
input = u.h('textarea', { id: id, className: 'form-field__textarea' });
|
||
} else {
|
||
let inputType = 'text';
|
||
if (fmt === 'date') {
|
||
inputType = 'date';
|
||
} else if (fmt === 'email') {
|
||
inputType = 'email';
|
||
}
|
||
input = u.h('input', { type: inputType, id: id, className: 'form-field__input' });
|
||
}
|
||
if (placeholder) {
|
||
input.placeholder = placeholder;
|
||
}
|
||
if (value != null) {
|
||
input.value = String(value);
|
||
}
|
||
if (readonly) {
|
||
input.readOnly = true;
|
||
}
|
||
if (autofocus) {
|
||
input.autofocus = true;
|
||
}
|
||
read = function () {
|
||
return input.value === '' ? undefined : input.value;
|
||
};
|
||
}
|
||
|
||
const built = fieldContainer({
|
||
id: id,
|
||
label: label,
|
||
description: description,
|
||
help: help,
|
||
required: required,
|
||
input: input
|
||
});
|
||
|
||
return widgetObject(built.wrap, built.errEl, path, read);
|
||
}
|
||
|
||
// Common widget shape used by both primitive and the wrapper above.
|
||
function widgetObject(wrapEl, errEl, path, read) {
|
||
return {
|
||
el: wrapEl,
|
||
path: path,
|
||
type: 'primitive',
|
||
read: read,
|
||
setError: function (msg) {
|
||
errEl.textContent = msg;
|
||
errEl.hidden = false;
|
||
wrapEl.classList.add('form-field--invalid');
|
||
},
|
||
clearErrors: function () {
|
||
errEl.textContent = '';
|
||
errEl.hidden = true;
|
||
wrapEl.classList.remove('form-field--invalid');
|
||
},
|
||
child: function () { return null; }
|
||
};
|
||
}
|
||
|
||
app.modules.widgets = { makePrimitive: makePrimitive };
|
||
})(window.formApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const u = app.modules.util;
|
||
|
||
function makeObject(schema, ui, path, value, options) {
|
||
const fs = u.h('fieldset', { className: 'form-fieldset' });
|
||
const label = (ui && ui['ui:title']) || schema.title || options.fieldName;
|
||
if (label) {
|
||
fs.appendChild(u.h('legend', { className: 'form-fieldset__legend' }, label));
|
||
}
|
||
if (schema.description) {
|
||
fs.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
|
||
}
|
||
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
|
||
fs.appendChild(errEl);
|
||
|
||
const props = schema.properties || {};
|
||
const requiredSet = {};
|
||
(schema.required || []).forEach(function (n) { requiredSet[n] = true; });
|
||
|
||
// Resolve render order: ui:order first (with '*' as "everything else"),
|
||
// then fall back to declaration order.
|
||
const declared = Object.keys(props);
|
||
const uiOrder = (ui && ui['ui:order']) || null;
|
||
const ordered = [];
|
||
const seen = {};
|
||
if (uiOrder && Array.isArray(uiOrder)) {
|
||
for (let i = 0; i < uiOrder.length; i++) {
|
||
const name = uiOrder[i];
|
||
if (name === '*') {
|
||
for (let j = 0; j < declared.length; j++) {
|
||
const dn = declared[j];
|
||
if (!seen[dn] && uiOrder.indexOf(dn) < 0) {
|
||
ordered.push(dn);
|
||
seen[dn] = true;
|
||
}
|
||
}
|
||
} else if (props[name] && !seen[name]) {
|
||
ordered.push(name);
|
||
seen[name] = true;
|
||
}
|
||
}
|
||
// Append anything declared but not mentioned in ui:order (and no '*' was used).
|
||
for (let j = 0; j < declared.length; j++) {
|
||
if (!seen[declared[j]]) {
|
||
ordered.push(declared[j]);
|
||
seen[declared[j]] = true;
|
||
}
|
||
}
|
||
} else {
|
||
for (let j = 0; j < declared.length; j++) {
|
||
ordered.push(declared[j]);
|
||
}
|
||
}
|
||
|
||
const children = {};
|
||
const dataObj = (value && typeof value === 'object' && !Array.isArray(value)) ? value : {};
|
||
|
||
for (let i = 0; i < ordered.length; i++) {
|
||
const name = ordered[i];
|
||
const childSchema = props[name];
|
||
const childUi = (ui && ui[name]) || {};
|
||
const childPath = u.ptrPush(path, name);
|
||
const childValue = dataObj[name];
|
||
const childWidget = app.modules.render.create(childSchema, childUi, childPath, childValue, {
|
||
fieldName: u.humanize(name),
|
||
required: !!requiredSet[name]
|
||
});
|
||
children[name] = childWidget;
|
||
fs.appendChild(childWidget.el);
|
||
}
|
||
|
||
return {
|
||
el: fs,
|
||
path: path,
|
||
type: 'object',
|
||
read: function () {
|
||
const out = {};
|
||
const keys = Object.keys(children);
|
||
for (let i = 0; i < keys.length; i++) {
|
||
const k = keys[i];
|
||
const v = children[k].read();
|
||
if (v !== undefined) {
|
||
out[k] = v;
|
||
}
|
||
}
|
||
return out;
|
||
},
|
||
setError: function (msg) {
|
||
errEl.textContent = msg;
|
||
errEl.hidden = false;
|
||
},
|
||
clearErrors: function () {
|
||
errEl.textContent = '';
|
||
errEl.hidden = true;
|
||
const keys = Object.keys(children);
|
||
for (let i = 0; i < keys.length; i++) {
|
||
children[keys[i]].clearErrors();
|
||
}
|
||
},
|
||
child: function (name) {
|
||
return children[name] || null;
|
||
}
|
||
};
|
||
}
|
||
|
||
app.modules.object = { makeObject: makeObject };
|
||
})(window.formApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const u = app.modules.util;
|
||
|
||
function makeArray(schema, ui, path, value, options) {
|
||
const wrap = u.h('div', { className: 'form-field form-array' });
|
||
const label = (ui && ui['ui:title']) || schema.title || options.fieldName || '';
|
||
if (label) {
|
||
const lbl = u.h('label', { className: 'form-field__label' });
|
||
lbl.appendChild(document.createTextNode(label));
|
||
if (options.required) {
|
||
lbl.appendChild(u.h('span', { className: 'required-mark' }, '*'));
|
||
}
|
||
wrap.appendChild(lbl);
|
||
}
|
||
if (schema.description) {
|
||
wrap.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
|
||
}
|
||
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
|
||
wrap.appendChild(errEl);
|
||
|
||
const rowsEl = u.h('div', { className: 'form-array__rows' });
|
||
wrap.appendChild(rowsEl);
|
||
|
||
const itemSchema = schema.items || { type: 'string' };
|
||
const itemUi = (ui && ui.items) || {};
|
||
const uiOpts = (ui && ui['ui:options']) || {};
|
||
const addable = uiOpts.addable !== false;
|
||
const removable = uiOpts.removable !== false;
|
||
|
||
const rows = [];
|
||
|
||
function repath() {
|
||
for (let i = 0; i < rows.length; i++) {
|
||
rows[i].widget.path = u.ptrPush(path, String(i));
|
||
}
|
||
}
|
||
|
||
function addRow(rowValue) {
|
||
const idx = rows.length;
|
||
const rowPath = u.ptrPush(path, String(idx));
|
||
const childWidget = app.modules.render.create(itemSchema, itemUi, rowPath, rowValue, {
|
||
fieldName: '',
|
||
required: false
|
||
});
|
||
|
||
const rowEl = u.h('div', { className: 'form-array__row' });
|
||
const body = u.h('div', { className: 'form-array__row-body' });
|
||
body.appendChild(childWidget.el);
|
||
rowEl.appendChild(body);
|
||
|
||
if (removable) {
|
||
const actions = u.h('div', { className: 'form-array__row-actions' });
|
||
const removeBtn = u.h('button', {
|
||
type: 'button',
|
||
className: 'btn btn-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');
|
||
} 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);
|
||
}
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||
} else {
|
||
boot();
|
||
}
|
||
})(window.formApp);
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|