First stable bump for the HTML tools since v0.0.1 — drags the stable
channel forward to absorb the months of work that has been riding
alpha (landing rework, presets cleanup, mdedit module split, shared
build-lib changes, etc.).
Each tool independently bumped to v0.0.2 (the tools are independently
versioned by git-tag prefix; their numbers do not need to align with
each other or with zddc-server's 0.0.6).
Per-tool changes:
- website/releases/<tool>_v0.0.2.html new immutable snapshot
- website/releases/<tool>_stable.html symlink → _v0.0.2.html
- website/releases/<tool>_alpha.html freshened from v0.0.2 tag
- website/releases/<tool>_beta.html freshened from v0.0.2 tag
Tags created locally and pushed alongside this commit:
archive-v0.0.2, transmittal-v0.0.2, classifier-v0.0.2,
mdedit-v0.0.2, landing-v0.0.2
Bootstrap zips (install.zip, track-{alpha,beta,stable}.zip) regenerated
by the same build pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10999 lines
410 KiB
HTML
10999 lines
410 KiB
HTML
<!--
|
||
This is a html transmittal template.
|
||
It is meant to be an editable form to fill out transmittal information and
|
||
scan documents to be included in the transmittal, and then complete the
|
||
transmittal files table by parsing the file names according to ZDDC naming
|
||
conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
|
||
-->
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
|
||
<head>
|
||
<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);
|
||
}
|
||
|
||
.btn-success {
|
||
background: var(--success);
|
||
color: var(--text-light);
|
||
border-color: var(--success);
|
||
}
|
||
|
||
.btn-danger {
|
||
background: var(--danger);
|
||
color: var(--text-light);
|
||
border-color: var(--danger);
|
||
}
|
||
|
||
/* Sizes */
|
||
.btn-sm {
|
||
padding: 0.25rem 0.5rem;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.btn-lg {
|
||
padding: 0.6rem 1.4rem;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.btn-link {
|
||
background: transparent;
|
||
border-color: transparent;
|
||
color: var(--primary);
|
||
padding-left: 0;
|
||
padding-right: 0;
|
||
}
|
||
|
||
.btn-link:not(:disabled):hover {
|
||
text-decoration: underline;
|
||
box-shadow: none;
|
||
}
|
||
|
||
/* ── App header chrome ────────────────────────────────────────────────────── */
|
||
.app-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.35rem 1rem;
|
||
background: var(--bg-secondary);
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Tool name inside the header */
|
||
.app-header__title {
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
letter-spacing: 0.01em;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ── Build timestamp ──────────────────────────────────────────────────────── */
|
||
.build-timestamp {
|
||
font-size: 0.55rem;
|
||
color: var(--text-muted);
|
||
opacity: 0.7;
|
||
font-weight: 300;
|
||
white-space: nowrap;
|
||
padding-top: 0.15rem;
|
||
}
|
||
|
||
/* Title + timestamp stacked vertically on the left side of the header */
|
||
.header-title-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
line-height: 1;
|
||
}
|
||
|
||
/* ── Icon buttons (help, theme, refresh) ─────────────────────────────────── */
|
||
/* Square, centered — overrides the asymmetric text-button padding/line-height */
|
||
#help-btn,
|
||
#theme-btn,
|
||
#refreshHeaderBtn {
|
||
width: 2rem;
|
||
height: 2rem;
|
||
padding: 0;
|
||
line-height: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
|
||
|
||
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
|
||
#theme-btn,
|
||
#help-btn {
|
||
font-size: 1rem;
|
||
}
|
||
|
||
/* ── Help panel (shared slide-out drawer) ─────────────────────────────────── */
|
||
/* Used by all four tools. Toggle open/close via shared/help.js. */
|
||
|
||
.help-panel {
|
||
position: fixed;
|
||
top: 0;
|
||
right: 0;
|
||
width: min(420px, 85vw);
|
||
height: 100vh;
|
||
z-index: 1000;
|
||
background: var(--bg);
|
||
border-left: 1px solid var(--border);
|
||
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.08);
|
||
display: flex;
|
||
flex-direction: column;
|
||
transform: translateX(100%);
|
||
transition: transform 0.25s ease;
|
||
}
|
||
|
||
.help-panel:not([hidden]) {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.help-panel[hidden] {
|
||
display: flex;
|
||
transform: translateX(100%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.help-panel__header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.75rem 1rem;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.help-panel__title {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
margin: 0;
|
||
}
|
||
|
||
.help-panel__close {
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-muted);
|
||
font-size: 1.35rem;
|
||
cursor: pointer;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: var(--radius);
|
||
line-height: 1;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
|
||
.help-panel__close:hover {
|
||
color: var(--text);
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.help-panel__body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 1rem 1rem 2rem;
|
||
font-size: 0.85rem;
|
||
line-height: 1.6;
|
||
color: var(--text);
|
||
}
|
||
|
||
.help-panel__body h3 {
|
||
font-size: 0.95rem;
|
||
font-weight: 700;
|
||
margin: 1.25rem 0 0.35rem;
|
||
color: var(--text);
|
||
border-bottom: 1px solid var(--border);
|
||
padding-bottom: 0.15rem;
|
||
}
|
||
|
||
.help-panel__body h3:first-child {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.help-panel__body h4 {
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
margin: 1.25rem 0 0.3rem;
|
||
padding-left: 0.5rem;
|
||
border-left: 3px solid var(--border-dark);
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.help-panel__body p {
|
||
margin: 0 0 0.5rem;
|
||
}
|
||
|
||
.help-panel__body ol,
|
||
.help-panel__body ul {
|
||
padding-left: 1.5rem;
|
||
margin: 0.3rem 0 0.5rem;
|
||
}
|
||
|
||
.help-panel__body li {
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
|
||
.help-panel__body dl {
|
||
margin: 0.3rem 0;
|
||
}
|
||
|
||
.help-panel__body dt {
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
}
|
||
|
||
.help-panel__body dd {
|
||
margin: 0 0 0.5rem 1rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.help-panel__body code {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.8em;
|
||
background: var(--bg-secondary);
|
||
padding: 0.1em 0.3em;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.help-badge {
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
padding: 0.1rem 0.35rem;
|
||
border-radius: var(--radius);
|
||
vertical-align: middle;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.help-badge--draft {
|
||
color: #2563eb;
|
||
background: #eff6ff;
|
||
}
|
||
|
||
.help-badge--published {
|
||
color: #7c3aed;
|
||
background: #f5f3ff;
|
||
}
|
||
|
||
/* Shrink main content when help panel is open */
|
||
body.help-open .app-header {
|
||
margin-right: min(420px, 85vw);
|
||
}
|
||
|
||
/* ── Column filter inputs (shared across archive, classifier, transmittal) ─── */
|
||
.column-filter {
|
||
display: block;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
margin-top: 0.25rem;
|
||
padding: 0.2rem 0.4rem;
|
||
font-size: 0.8rem;
|
||
font-family: var(--font);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
transition: border-color 0.15s;
|
||
}
|
||
|
||
.column-filter:focus {
|
||
border-color: var(--primary);
|
||
outline: none;
|
||
box-shadow: 0 0 0 1px rgba(42, 90, 138, 0.35);
|
||
}
|
||
|
||
.column-filter::placeholder {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* Placeholder for contenteditable elements */
|
||
[data-placeholder]:empty::before {
|
||
content: attr(data-placeholder);
|
||
color: var(--text-muted);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Hide elements that should be hidden when JavaScript is available */
|
||
[data-hydrate-hide] {
|
||
display: none;
|
||
}
|
||
|
||
@media screen {
|
||
body {
|
||
margin: 0;
|
||
padding: 0;
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.stack-below-600 {
|
||
display: flex;
|
||
flex-direction: row;
|
||
}
|
||
|
||
.page-container {
|
||
width: 100%;
|
||
max-width: 8.5in;
|
||
margin: 20px auto;
|
||
padding: 0.375in;
|
||
min-height: 0;
|
||
background: var(--bg);
|
||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||
border: 1px solid var(--border);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.stack-below-600 {
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.page-container {
|
||
padding: 12px;
|
||
}
|
||
}
|
||
|
||
/* ── Dark mode overrides for transmittal-specific hardcoded colours ──────── */
|
||
/* Covers verify cards, table rows, path-diff, integrity cards, workflow badge */
|
||
/* and Tailwind utility classes used as attributes in template.html. */
|
||
@media screen {
|
||
/* Integrity verify cards */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) .verify-card--ok { background: #052e16; }
|
||
:root:not([data-theme="light"]) .verify-card--fail { background: #2d0c0c; }
|
||
:root:not([data-theme="light"]) .verify-card--draft { background: #2d2305; }
|
||
:root:not([data-theme="light"]) .verify-card--info { background: #0c1a2e; }
|
||
}
|
||
[data-theme="dark"] .verify-card--ok { background: #052e16; }
|
||
[data-theme="dark"] .verify-card--fail { background: #2d0c0c; }
|
||
[data-theme="dark"] .verify-card--draft { background: #2d2305; }
|
||
[data-theme="dark"] .verify-card--info { background: #0c1a2e; }
|
||
|
||
/* Per-row verify result rows in file table */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) tr.verify-match td { background: #052e16; }
|
||
:root:not([data-theme="light"]) tr.verify-mismatch td { background: #2d0c0c; }
|
||
:root:not([data-theme="light"]) tr.verify-missing td { background: #2d2305; }
|
||
:root:not([data-theme="light"]) tr.verify-new td { background: #0c1a2e; }
|
||
}
|
||
[data-theme="dark"] tr.verify-match td { background: #052e16; }
|
||
[data-theme="dark"] tr.verify-mismatch td { background: #2d0c0c; }
|
||
[data-theme="dark"] tr.verify-missing td { background: #2d2305; }
|
||
[data-theme="dark"] tr.verify-new td { background: #0c1a2e; }
|
||
|
||
/* Workflow warning badge */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) .workflow-badge--warn {
|
||
background: #2d2305;
|
||
color: #fcd34d;
|
||
border-color: #92400e;
|
||
}
|
||
}
|
||
[data-theme="dark"] .workflow-badge--warn {
|
||
background: #2d2305;
|
||
color: #fcd34d;
|
||
border-color: #92400e;
|
||
}
|
||
|
||
/* Path diff semantic colours (verify mismatch) */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) .path-diff del {
|
||
color: #fca5a5;
|
||
background: #2d0c0c;
|
||
}
|
||
:root:not([data-theme="light"]) .path-diff ins {
|
||
color: #86efac;
|
||
background: #052e16;
|
||
border-bottom-color: #86efac;
|
||
}
|
||
}
|
||
[data-theme="dark"] .path-diff del { color: #fca5a5; background: #2d0c0c; }
|
||
[data-theme="dark"] .path-diff ins {
|
||
color: #86efac;
|
||
background: #052e16;
|
||
border-bottom-color: #86efac;
|
||
}
|
||
|
||
/* Owner/Project names area and inline bg-white / bg-gray-50 utility classes */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) .header-names {
|
||
background-color: var(--bg-secondary) !important;
|
||
border-color: var(--border) !important;
|
||
}
|
||
:root:not([data-theme="light"]) .text-gray-700 { color: var(--text-muted) !important; }
|
||
:root:not([data-theme="light"]) .bg-white { background-color: var(--bg) !important; }
|
||
:root:not([data-theme="light"]) .bg-gray-50 { background-color: var(--bg-secondary) !important; }
|
||
:root:not([data-theme="light"]) .bg-gray-100 { background-color: var(--bg-secondary) !important; }
|
||
:root:not([data-theme="light"]) .border-gray-100,
|
||
:root:not([data-theme="light"]) .border-gray-200,
|
||
:root:not([data-theme="light"]) .border-gray-300 { border-color: var(--border) !important; }
|
||
:root:not([data-theme="light"]) .text-gray-900 { color: var(--text) !important; }
|
||
}
|
||
[data-theme="dark"] .header-names {
|
||
background-color: var(--bg-secondary) !important;
|
||
border-color: var(--border) !important;
|
||
}
|
||
[data-theme="dark"] .text-gray-700 { color: var(--text-muted) !important; }
|
||
[data-theme="dark"] .bg-white { background-color: var(--bg) !important; }
|
||
[data-theme="dark"] .bg-gray-50 { background-color: var(--bg-secondary) !important; }
|
||
[data-theme="dark"] .bg-gray-100 { background-color: var(--bg-secondary) !important; }
|
||
[data-theme="dark"] .border-gray-100,
|
||
[data-theme="dark"] .border-gray-200,
|
||
[data-theme="dark"] .border-gray-300 { border-color: var(--border) !important; }
|
||
[data-theme="dark"] .text-gray-900 { color: var(--text) !important; }
|
||
|
||
/* Filter inputs in table column headers */
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) .table-filter-input {
|
||
background-color: var(--bg);
|
||
color: var(--text);
|
||
border-color: var(--border);
|
||
}
|
||
:root:not([data-theme="light"]) .table-header__caption { color: var(--text-muted); }
|
||
:root:not([data-theme="light"]) .focus\:bg-white:focus { background-color: var(--bg) !important; }
|
||
}
|
||
[data-theme="dark"] .table-filter-input {
|
||
background-color: var(--bg);
|
||
color: var(--text);
|
||
border-color: var(--border);
|
||
}
|
||
[data-theme="dark"] .table-header__caption { color: var(--text-muted); }
|
||
[data-theme="dark"] .focus\:bg-white:focus { background-color: var(--bg) !important; }
|
||
}
|
||
|
||
/* Logo row: flex layout — logo | title | logo */
|
||
.logo-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
width: 100%;
|
||
}
|
||
|
||
.logo-cell {
|
||
width: 250px;
|
||
height: 60px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.logo-img {
|
||
display: block;
|
||
width: 250px;
|
||
height: 60px;
|
||
object-fit: contain;
|
||
user-select: none;
|
||
}
|
||
|
||
#left-logo-cell .logo-img { object-position: left center; }
|
||
#right-logo-cell .logo-img { object-position: right center; }
|
||
|
||
.logo-img:not([src]),
|
||
.logo-img[src=""] {
|
||
display: none;
|
||
}
|
||
|
||
.logo-placeholder {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
border: 2px dashed var(--border-dark);
|
||
border-radius: 0.375rem;
|
||
color: var(--text-muted);
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.logo-cell.has-logo .logo-placeholder,
|
||
.logo-placeholder.hidden {
|
||
display: none;
|
||
}
|
||
|
||
.title-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 2px;
|
||
text-align: center;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Type combo dropdown */
|
||
.type-combo {
|
||
position: relative;
|
||
width: 100%;
|
||
}
|
||
|
||
.type-display {
|
||
display: block;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
outline: none;
|
||
min-height: 1.5em;
|
||
}
|
||
|
||
.type-display:empty::before {
|
||
content: attr(data-placeholder);
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.type-combo__menu {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 0.375rem;
|
||
box-shadow: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -2px rgba(0,0,0,.1);
|
||
z-index: 50;
|
||
min-width: 160px;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.type-combo__option {
|
||
display: block;
|
||
width: 100%;
|
||
text-align: center;
|
||
padding: 6px 16px;
|
||
font-size: 0.875rem;
|
||
font-weight: 600;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: var(--text);
|
||
}
|
||
|
||
.type-combo__option:hover {
|
||
background: var(--bg-hover);
|
||
color: var(--primary);
|
||
}
|
||
|
||
/* ── Targeted drop zones ─────────────────────────────────────────────────── */
|
||
|
||
/* Base: relative so the label can be absolute-positioned inside */
|
||
[data-drop-zone] {
|
||
position: relative;
|
||
border-radius: 0.375rem;
|
||
transition: outline 0.12s, background 0.12s, opacity 0.12s;
|
||
}
|
||
|
||
/* Label: hidden by default, centered overlay inside the zone */
|
||
.drop-zone-label {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: absolute;
|
||
inset: 0;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 0.375rem;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
text-align: center;
|
||
line-height: 1.3;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.12s;
|
||
z-index: 30;
|
||
white-space: pre-line;
|
||
}
|
||
|
||
/* --- Visible state: outline appears, label fades in --- */
|
||
[data-drop-zone].dz-visible .drop-zone-label {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* Eligible zone: blue dashed outline + subtle blue tint */
|
||
[data-drop-zone].dz-eligible {
|
||
outline: 2px dashed #60a5fa;
|
||
outline-offset: 2px;
|
||
background: rgba(239, 246, 255, 0.55);
|
||
}
|
||
[data-drop-zone].dz-eligible .drop-zone-label {
|
||
color: #1e40af;
|
||
background: rgba(239, 246, 255, 0.9);
|
||
}
|
||
|
||
/* Ineligible zone: grey dashed outline, dimmed */
|
||
[data-drop-zone].dz-ineligible {
|
||
outline: 2px dashed #d1d5db;
|
||
outline-offset: 2px;
|
||
opacity: 0.5;
|
||
}
|
||
[data-drop-zone].dz-ineligible .drop-zone-label {
|
||
color: #6b7280;
|
||
background: rgba(249, 250, 251, 0.85);
|
||
}
|
||
|
||
/* Hover over eligible zone: solid outline, stronger tint, white-on-blue label */
|
||
[data-drop-zone].dz-hover {
|
||
outline: 2px solid #2563eb;
|
||
outline-offset: 2px;
|
||
background: rgba(219, 234, 254, 0.85);
|
||
opacity: 1;
|
||
}
|
||
[data-drop-zone].dz-hover .drop-zone-label {
|
||
color: #1e3a8a;
|
||
background: rgba(191, 219, 254, 0.95);
|
||
font-weight: 700;
|
||
}
|
||
|
||
/* Logo cells: label sits centered over the cell content */
|
||
#left-logo-cell[data-drop-zone] .drop-zone-label,
|
||
#right-logo-cell[data-drop-zone] .drop-zone-label {
|
||
font-size: 0.65rem;
|
||
}
|
||
|
||
/* File-table zone wrapper: display:block, no extra spacing */
|
||
#file-table-drop-zone {
|
||
display: block;
|
||
}
|
||
|
||
@media screen {
|
||
.page-header .header-names {
|
||
padding: 6px 8px;
|
||
}
|
||
|
||
.page-header .header-names h2 {
|
||
margin: 0;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.page-header #project-number {
|
||
margin: 2px 0 6px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.page-header .grid {
|
||
column-gap: 1rem;
|
||
row-gap: 0.5rem;
|
||
}
|
||
|
||
/* .action-button removed — transmittal buttons now use .btn/.btn-primary/.btn-secondary */
|
||
|
||
|
||
/* ── Integrity section layout ──────────────────────── */
|
||
.integrity-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 0.5rem;
|
||
width: 100%;
|
||
}
|
||
|
||
/* ── Verify cards ──────────────────────────────────── */
|
||
.verify-card {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
align-items: baseline;
|
||
gap: 0.25rem 0.75rem;
|
||
padding: 0.5rem 0.75rem;
|
||
border-left: 4px solid;
|
||
border-radius: 0.25rem;
|
||
font-size: 0.75rem;
|
||
line-height: 1.5;
|
||
}
|
||
.verify-card--ok { background: #f0fdf4; border-left-color: #22c55e; }
|
||
.verify-card--fail { background: #fef2f2; border-left-color: #ef4444; }
|
||
.verify-card--draft { background: #fefce8; border-left-color: #f59e0b; }
|
||
.verify-card--info { background: #eff6ff; border-left-color: #3b82f6; }
|
||
|
||
.verify-card__status {
|
||
font-weight: 700;
|
||
font-size: 0.8rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.375rem;
|
||
}
|
||
.verify-card__status--ok { color: #166534; }
|
||
.verify-card__status--fail { color: #991b1b; }
|
||
.verify-card__status--draft { color: #92400e; }
|
||
.verify-card__status--info { color: #1e40af; }
|
||
|
||
.verify-card__detail {
|
||
font-size: 0.7rem;
|
||
color: var(--text-muted);
|
||
}
|
||
.verify-card__detail code {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.65rem;
|
||
word-break: break-all;
|
||
color: var(--primary);
|
||
}
|
||
|
||
/* Workflow Area */
|
||
.workflow-area {
|
||
border-top: 1px solid var(--border);
|
||
padding-top: 0.75rem;
|
||
margin-top: 0.75rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.875rem;
|
||
}
|
||
|
||
.workflow-step {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.375rem;
|
||
}
|
||
|
||
.workflow-step__label {
|
||
font-size: 0.6875rem;
|
||
font-weight: 700;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
margin: 0;
|
||
}
|
||
|
||
.workflow-step__body {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.workflow-dir {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.625rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.workflow-hint {
|
||
font-size: 0.625rem;
|
||
color: var(--text-muted);
|
||
font-style: italic;
|
||
}
|
||
|
||
.workflow-badge {
|
||
font-size: 0.625rem;
|
||
padding: 0.0625rem 0.375rem;
|
||
border: 1px solid;
|
||
border-radius: 0.25rem;
|
||
margin-left: 0.375rem;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.workflow-badge--warn {
|
||
background: #fef9c3;
|
||
color: #92400e;
|
||
border-color: #fcd34d;
|
||
}
|
||
|
||
/* Tools row — compact, de-emphasized */
|
||
.workflow-tools {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.375rem;
|
||
padding-top: 0.5rem;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
/* ── Table toolbar (above table) ───────────────────────── */
|
||
.table-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
gap: 0.25rem 0.5rem;
|
||
padding: 0.375rem 0;
|
||
border-top: 1px solid var(--border);
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.toolbar-btn {
|
||
background: none;
|
||
border: none;
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
color: var(--primary);
|
||
cursor: pointer;
|
||
padding: 0.15rem 0.35rem;
|
||
border-radius: 0.25rem;
|
||
}
|
||
|
||
.toolbar-btn:hover {
|
||
text-decoration: underline;
|
||
background: var(--primary-light);
|
||
}
|
||
|
||
/* ── App chrome header (lives outside the transmittal) ── */
|
||
/* shared/base.css provides display, align-items, background, border-bottom */
|
||
.app-header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1200;
|
||
gap: 0.5rem;
|
||
padding: 0.3rem 0.75rem;
|
||
/* Expose height for downstream sticky offsets */
|
||
--app-header-height: 2.1rem;
|
||
height: var(--app-header-height);
|
||
box-sizing: content-box;
|
||
}
|
||
|
||
.app-header__spacer {
|
||
flex: 1;
|
||
}
|
||
|
||
.app-header__icons {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.header-icon-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 0.25rem;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
text-decoration: none;
|
||
transition: color 0.15s, background 0.15s;
|
||
}
|
||
|
||
.header-icon-btn:hover {
|
||
color: var(--primary-hover);
|
||
background: var(--primary-light);
|
||
}
|
||
|
||
/* ── Fixed footer status bar at viewport bottom ───────── */
|
||
.page-footer {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 50;
|
||
background: var(--bg-secondary);
|
||
border-top: 1px solid var(--border);
|
||
padding: 0.2rem 0.75rem;
|
||
}
|
||
|
||
.page-footer__inner {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
max-width: 8.5in;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.page-container {
|
||
padding-bottom: 2rem;
|
||
}
|
||
|
||
/* ── Split-button ─────────────────────────────────────── */
|
||
.split-button {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.split-button > .btn:last-of-type {
|
||
border-top-left-radius: 0;
|
||
border-bottom-left-radius: 0;
|
||
}
|
||
|
||
.split-button__toggle {
|
||
border-top-right-radius: 0;
|
||
border-bottom-right-radius: 0;
|
||
border-right: 1px solid rgba(255, 255, 255, 0.3);
|
||
min-width: 28px;
|
||
padding-left: 6px;
|
||
padding-right: 6px;
|
||
font-size: 0.75rem;
|
||
line-height: 1;
|
||
}
|
||
|
||
/* ── Dropdown menu ──────────────────────────────────────── */
|
||
.dropdown {
|
||
position: relative;
|
||
display: inline-block;
|
||
}
|
||
|
||
.dropdown-toggle {
|
||
min-width: 28px;
|
||
font-size: 1rem;
|
||
letter-spacing: 0.1em;
|
||
line-height: 1;
|
||
}
|
||
|
||
.dropdown-menu {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 100%;
|
||
margin-top: 2px;
|
||
min-width: 120px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 0.375rem;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
z-index: 1100;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.dropdown-menu.dropdown-menu--up {
|
||
bottom: 100%;
|
||
top: auto;
|
||
margin-top: 0;
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
/* ── Dropdown separators ─────────────────────────────────── */
|
||
.dropdown-separator {
|
||
border-top: 1px solid var(--border);
|
||
margin: 4px 0;
|
||
padding: 0;
|
||
font-size: 0.625rem;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.dropdown-separator:empty {
|
||
padding: 0;
|
||
}
|
||
|
||
.dropdown-separator:not(:empty) {
|
||
padding: 4px 12px 2px;
|
||
}
|
||
|
||
/* ── Scanning blur on table ─────────────────────────────── */
|
||
.table-wrapper.scanning {
|
||
filter: blur(1px);
|
||
opacity: 0.7;
|
||
pointer-events: none;
|
||
transition: filter 0.3s ease, opacity 0.3s ease;
|
||
}
|
||
|
||
.table-wrapper {
|
||
transition: filter 0.3s ease, opacity 0.3s ease;
|
||
}
|
||
|
||
/* ── Hash validation indicators ──────────────────────────── */
|
||
.hash-match {
|
||
color: #16a34a;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.hash-mismatch {
|
||
color: #dc2626;
|
||
font-weight: 700;
|
||
}
|
||
|
||
/* ── Inline hash progress bar ────────────────────────────── */
|
||
.hash-progress {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
width: 100%;
|
||
}
|
||
|
||
.hash-progress-bar {
|
||
flex: 1;
|
||
height: 6px;
|
||
background: var(--border);
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.hash-progress-fill {
|
||
height: 100%;
|
||
width: 0%;
|
||
background: var(--primary);
|
||
border-radius: 3px;
|
||
transition: width 0.15s ease;
|
||
}
|
||
|
||
.hash-progress-fill.done {
|
||
background: #22c55e;
|
||
width: 100%;
|
||
}
|
||
|
||
/* ── Per-row verify result styling ───────────────────────── */
|
||
tr.verify-match td { background: #dcfce7; }
|
||
tr.verify-mismatch td { background: #fee2e2; }
|
||
tr.verify-missing td { background: #fef3c7; }
|
||
tr.verify-new td { background: #dbeafe; }
|
||
tr.verify-progress td:last-child { color: #6b7280; font-style: italic; }
|
||
|
||
.dropdown-item {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 6px 12px;
|
||
border: none;
|
||
background: none;
|
||
text-align: left;
|
||
font-size: 0.8rem;
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.dropdown-item:hover {
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.dropdown-item:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
}
|
||
|
||
@media screen {
|
||
#transmittal-form .page-header .relative > input[type="text"] {
|
||
padding-top: 0.625rem;
|
||
padding-bottom: 0.25rem;
|
||
border-top: 0 !important;
|
||
border-left: 0 !important;
|
||
border-right: 0 !important;
|
||
border-bottom: 1px solid var(--border) !important;
|
||
border-radius: 0 !important;
|
||
background-color: var(--bg);
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
#transmittal-form .page-header .relative > input[type="text"]:focus {
|
||
border-bottom-color: var(--primary);
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
/* Remove underlines from title area inputs (type and title) */
|
||
#transmittal-form .page-header .title-area input[type="text"] {
|
||
border-bottom: 0 !important;
|
||
}
|
||
|
||
#transmittal-form .page-header label[for],
|
||
#transmittal-form .page-header .relative > label {
|
||
position: absolute;
|
||
left: 0.5rem;
|
||
top: -0.5rem;
|
||
font-weight: 700;
|
||
background: var(--bg);
|
||
padding: 0 0.25rem;
|
||
font-size: 10px;
|
||
color: var(--text-muted);
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
}
|
||
|
||
#transmittal-form .page-header .relative {
|
||
margin-top: 0.375rem;
|
||
}
|
||
|
||
/* Boxed inputs (full-border, floating label) inside the grid need extra top
|
||
margin so the absolute-positioned label (-top-2 = -0.5rem) is not clipped */
|
||
#transmittal-form .page-header .grid .relative {
|
||
margin-top: 0.75rem;
|
||
}
|
||
|
||
/* ── From field rendered mailto link ──────────────────────── */
|
||
.from-render {
|
||
font-size: 12px;
|
||
font-family: var(--font-mono);
|
||
line-height: 1.6;
|
||
padding: 0.625rem 0.5rem 0.25rem;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.from-mailto {
|
||
color: var(--primary);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.from-mailto:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* ── To field rendered mailto links ───────────────────────── */
|
||
.to-render {
|
||
font-size: 12px;
|
||
font-family: var(--font-mono);
|
||
line-height: 1.6;
|
||
padding: 0.625rem 0.5rem 0.25rem;
|
||
border-bottom: 1px solid var(--border);
|
||
word-break: break-word;
|
||
}
|
||
|
||
.to-mailto {
|
||
color: var(--primary);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.to-mailto:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.to-sep {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
}
|
||
|
||
@media screen {
|
||
.table-wrapper {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.table-wrapper table {
|
||
table-layout: auto;
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
/* Header cells — stick below app-header (height + padding + border) */
|
||
.table-wrapper thead th {
|
||
white-space: nowrap;
|
||
position: sticky;
|
||
top: calc(2.1rem + 0.6rem + 1px);
|
||
z-index: 1000;
|
||
background-color: var(--bg-secondary);
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||
border-left: 1px solid var(--border);
|
||
border-right: 1px solid var(--border);
|
||
border-bottom: 1px solid var(--border);
|
||
border-top: none;
|
||
}
|
||
.table-wrapper thead th:first-child { border-left: none; }
|
||
.table-wrapper thead th:last-child { border-right: none; }
|
||
|
||
/* Body cells: all gray borders */
|
||
.table-wrapper tbody td {
|
||
white-space: nowrap;
|
||
border-left: 1px solid var(--border);
|
||
border-right: 1px solid var(--border);
|
||
border-top: 1px solid var(--border);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.table-wrapper tbody td:first-child { border-left: none; }
|
||
.table-wrapper tbody td:last-child { border-right: none; }
|
||
.table-wrapper tbody tr:first-child td { border-top: none; }
|
||
.table-wrapper tbody tr:last-child td { border-bottom: none; }
|
||
|
||
/* Self-entry row */
|
||
.table-wrapper tbody tr.self-entry td {
|
||
background-color: var(--bg-hover);
|
||
}
|
||
|
||
/* 3: Title — only column that wraps */
|
||
.table-wrapper thead th:nth-child(3) {
|
||
white-space: normal;
|
||
}
|
||
|
||
.table-wrapper tbody td:nth-child(3) {
|
||
white-space: normal;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
/* 7: Size — right aligned */
|
||
.table-wrapper thead th:nth-child(7),
|
||
.table-wrapper tbody td:nth-child(7) {
|
||
text-align: right;
|
||
}
|
||
|
||
/* Row delete button */
|
||
.row-delete-btn {
|
||
display: none;
|
||
border: none;
|
||
background: none;
|
||
color: #dc2626;
|
||
font-size: 0.85rem;
|
||
line-height: 1;
|
||
padding: 0 0.2rem;
|
||
margin-right: 0.15rem;
|
||
cursor: pointer;
|
||
vertical-align: middle;
|
||
opacity: 0.5;
|
||
}
|
||
.row-delete-btn:hover {
|
||
opacity: 1;
|
||
}
|
||
.table-wrapper tbody tr:hover .row-delete-btn {
|
||
display: inline;
|
||
}
|
||
|
||
/* Path diff indicator (verify mismatch) */
|
||
.path-diff {
|
||
font-size: 0.65rem;
|
||
line-height: 1.3;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.path-diff del {
|
||
color: #991b1b;
|
||
background: #fef2f2;
|
||
}
|
||
|
||
.path-diff ins {
|
||
color: #166534;
|
||
background: #f0fdf4;
|
||
text-decoration: none;
|
||
border-bottom: 1px solid #166534;
|
||
}
|
||
}
|
||
|
||
#remarks-render-container {
|
||
padding: 6px;
|
||
}
|
||
|
||
/* In edit mode the rendered preview is clickable to re-enter the editor */
|
||
#remarks-render-container.remarks-clickable {
|
||
cursor: text;
|
||
border: 1px dashed #d1d5db;
|
||
border-radius: 0.25rem;
|
||
min-height: 3.5rem;
|
||
}
|
||
|
||
#remarks-render-container.remarks-clickable:hover {
|
||
border-color: #93c5fd;
|
||
background: #f0f7ff;
|
||
}
|
||
|
||
/* Placeholder shown when remarks are empty and editable */
|
||
#remarks-render-container .remarks-placeholder {
|
||
color: #9ca3af;
|
||
font-style: italic;
|
||
font-size: 12px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
#remarks-render h1,
|
||
#remarks-render h2,
|
||
#remarks-render h3,
|
||
#remarks-render h4,
|
||
#remarks-render h5,
|
||
#remarks-render h6 {
|
||
margin: 0.5rem 0 0.25rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
#remarks-render h1 {
|
||
font-size: 1.25rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
#remarks-render h2 {
|
||
font-size: 1.125rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
#remarks-render h3 {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
#remarks-render h4 {
|
||
font-size: 0.9375rem;
|
||
}
|
||
|
||
#remarks-render h5 {
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
#remarks-render h6 {
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
#remarks-render p {
|
||
margin: 0.5rem 0 0.85rem;
|
||
}
|
||
|
||
#remarks-render ul {
|
||
list-style: disc inside;
|
||
padding-left: 0.75rem;
|
||
margin: 0.25rem 0;
|
||
}
|
||
|
||
#remarks-render ol {
|
||
list-style: decimal inside;
|
||
padding-left: 0.75rem;
|
||
margin: 0.25rem 0;
|
||
}
|
||
|
||
#remarks-render li {
|
||
margin: 0.125rem 0;
|
||
}
|
||
|
||
#remarks-render blockquote {
|
||
border-left: 3px solid #e5e7eb;
|
||
padding-left: 0.75rem;
|
||
color: #374151;
|
||
margin: 0.25rem 0;
|
||
}
|
||
|
||
#remarks-render pre {
|
||
background: #f9fafb;
|
||
border: 1px solid #e5e7eb;
|
||
padding: 0.5rem;
|
||
border-radius: 0.25rem;
|
||
overflow: auto;
|
||
}
|
||
|
||
#remarks-render code {
|
||
background: #f3f4f6;
|
||
border: 1px solid #e5e7eb;
|
||
padding: 0 0.25rem;
|
||
border-radius: 0.125rem;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||
}
|
||
|
||
#remarks-render hr {
|
||
border: 0;
|
||
border-top: 1px solid #e5e7eb;
|
||
margin: 0.5rem 0;
|
||
}
|
||
|
||
#remarks-render del {
|
||
text-decoration: line-through;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
#remarks-render a {
|
||
color: #2563eb;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
#remarks-render table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
margin: 0.25rem 0;
|
||
}
|
||
|
||
#remarks-render th,
|
||
#remarks-render td {
|
||
border: 1px solid #d1d5db;
|
||
padding: 0.25rem 0.5rem;
|
||
text-align: left;
|
||
}
|
||
|
||
#remarks-render th {
|
||
background: #f3f4f6;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* ── Button bar ───────────────────────────────────────────── */
|
||
.md-toolbar {
|
||
display: flex;
|
||
gap: 2px;
|
||
padding: 3px 4px;
|
||
background: #f3f4f6;
|
||
border: 1px solid #d1d5db;
|
||
border-bottom: none;
|
||
border-radius: 0.25rem 0.25rem 0 0;
|
||
}
|
||
|
||
.md-toolbar-btn {
|
||
min-width: 26px;
|
||
height: 24px;
|
||
padding: 0 5px;
|
||
border: 1px solid transparent;
|
||
border-radius: 3px;
|
||
background: transparent;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
}
|
||
|
||
.md-toolbar-btn:hover {
|
||
background: #e5e7eb;
|
||
border-color: #d1d5db;
|
||
}
|
||
|
||
.md-toolbar-btn:active {
|
||
background: #d1d5db;
|
||
}
|
||
|
||
/* ── Edit area ────────────────────────────────────────── */
|
||
.md-edit-area {
|
||
position: relative;
|
||
min-height: 80px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 0 0 0.25rem 0.25rem;
|
||
}
|
||
|
||
.md-input {
|
||
display: block;
|
||
width: 100%;
|
||
min-height: 80px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
padding: 6px 8px;
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
overflow-wrap: break-word;
|
||
tab-size: 4;
|
||
border: none;
|
||
outline: none;
|
||
resize: vertical;
|
||
background: #fff;
|
||
color: #111827;
|
||
}
|
||
|
||
.md-edit-area:focus-within {
|
||
border-color: #93c5fd;
|
||
box-shadow: 0 0 0 1px #93c5fd;
|
||
}
|
||
|
||
.table-header {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 0.2rem;
|
||
width: 100%;
|
||
padding: 0;
|
||
}
|
||
|
||
.table-header__caption {
|
||
font-weight: 600;
|
||
font-size: 0.7rem;
|
||
letter-spacing: 0.04em;
|
||
color: var(--text-muted);
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.table-header-cell {
|
||
padding: 0;
|
||
}
|
||
|
||
/* Transmittal uses a denser table — override shared .column-filter sizing */
|
||
.column-filter {
|
||
font-size: 0.65rem;
|
||
padding: 0.1rem 0.3rem;
|
||
margin-top: 0.15rem;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
/* ── Native <dialog> base ─────────────────────────────── */
|
||
dialog.modal {
|
||
border: none;
|
||
border-radius: 0.75rem;
|
||
max-width: 32rem;
|
||
width: min(90%, 32rem);
|
||
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
|
||
padding: 1.5rem;
|
||
gap: 1.25rem;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
}
|
||
|
||
dialog.modal[open] {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
dialog.modal::backdrop {
|
||
background: rgba(17, 24, 39, 0.55);
|
||
}
|
||
|
||
/* Prevent background scroll while a modal dialog is open */
|
||
html:has(dialog.modal[open]) {
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── Shared modal layout ─────────────────────────────── */
|
||
.modal__header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.modal__title {
|
||
font-size: 1.25rem;
|
||
line-height: 1.75rem;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
margin: 0;
|
||
}
|
||
|
||
.modal__close {
|
||
background: transparent;
|
||
border: none;
|
||
font-size: 1.5rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.modal__body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.modal__options {
|
||
display: grid;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.modal__footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.modal__feedback {
|
||
min-height: 1.25rem;
|
||
font-size: 0.85rem;
|
||
color: var(--danger);
|
||
}
|
||
|
||
/* ── Narrow modal variant ────────────────────────────── */
|
||
dialog.modal--narrow {
|
||
max-width: 24rem;
|
||
width: min(90%, 24rem);
|
||
}
|
||
|
||
/* ── Publish field styles ────────────────────────────── */
|
||
.publish-field__label {
|
||
display: block;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.publish-field__row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
.publish-field__input {
|
||
border: 1px solid var(--border);
|
||
border-radius: 0.375rem;
|
||
padding: 0.35rem 0.5rem;
|
||
font-size: 0.85rem;
|
||
width: 100%;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
}
|
||
|
||
.publish-field__warning {
|
||
font-size: 0.7rem;
|
||
color: #92400e;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.publish-outputs {
|
||
border: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
.publish-outputs legend {
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.publish-check {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
font-size: 0.8rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* ── Key dialog ──────────────────────────────────────── */
|
||
.key-dialog__desc {
|
||
font-size: 0.85rem;
|
||
color: var(--text-muted);
|
||
margin: 0 0 0.75rem;
|
||
}
|
||
|
||
.key-dialog__actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
/* ── Success notification ────────────────────────────── */
|
||
@keyframes notification-slide-in {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
|
||
.publish-notification {
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
background: #10b981;
|
||
color: white;
|
||
padding: 1rem 1.5rem;
|
||
border-radius: 0.5rem;
|
||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||
z-index: 10000;
|
||
max-width: 400px;
|
||
animation: notification-slide-in 0.3s ease-out;
|
||
}
|
||
|
||
.publish-notification__title {
|
||
font-weight: 600;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.publish-notification__file {
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.publish-notification__close {
|
||
margin-top: 0.5rem;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border: none;
|
||
color: white;
|
||
padding: 0.25rem 0.75rem;
|
||
border-radius: 0.25rem;
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
/* ── Responsive ──────────────────────────────────────── */
|
||
@media (max-width: 480px) {
|
||
dialog.modal {
|
||
padding: 1.25rem;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.modal__footer {
|
||
flex-direction: column-reverse;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.key-dialog__actions {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
}
|
||
|
||
/* Tailwind-inspired utility subset required by template.html */
|
||
|
||
/* Typography */
|
||
.font-sans { font-family: "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; }
|
||
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||
.font-semibold { font-weight: 600; }
|
||
.font-bold { font-weight: 700; }
|
||
.italic { font-style: italic; }
|
||
.text-left { text-align: left; }
|
||
.text-center { text-align: center; }
|
||
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||
.text-base { font-size: 1rem; line-height: 1.5rem; }
|
||
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
|
||
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
|
||
.text-\[12px\] { font-size: 12px; line-height: 1.4; }
|
||
.text-\[10px\] { font-size: 10px; line-height: 1.3; }
|
||
.text-gray-900 { color: #111827; }
|
||
.text-gray-700 { color: #374151; }
|
||
.text-gray-600 { color: #4b5563; }
|
||
.text-gray-500 { color: #6b7280; }
|
||
.text-gray-400 { color: #9ca3af; }
|
||
.text-blue-600 { color: #2563eb; }
|
||
.text-green-600 { color: #16a34a; }
|
||
.text-red-600 { color: #dc2626; }
|
||
.uppercase { text-transform: uppercase; }
|
||
.tracking-wide { letter-spacing: 0.1em; }
|
||
.leading-6 { line-height: 1.5rem; }
|
||
.leading-snug { line-height: 1.375rem; }
|
||
|
||
/* Backgrounds */
|
||
.bg-white { background-color: #ffffff; }
|
||
.bg-transparent { background-color: transparent; }
|
||
.bg-gray-50 { background-color: #f9fafb; }
|
||
.bg-gray-100 { background-color: #f3f4f6; }
|
||
|
||
/* Borders */
|
||
.border { border: 1px solid #d1d5db; }
|
||
.border-0 { border: 0; }
|
||
.border-b { border-bottom: 1px solid #d1d5db; }
|
||
.border-t { border-top: 1px solid #d1d5db; }
|
||
.border-gray-300 { border-color: #d1d5db; }
|
||
.border-gray-200 { border-color: #e5e7eb; }
|
||
.border-gray-100 { border-color: #f3f4f6; }
|
||
.rounded-none { border-radius: 0; }
|
||
.rounded-sm { border-radius: 0.125rem; }
|
||
.rounded { border-radius: 0.25rem; }
|
||
.rounded-md { border-radius: 0.375rem; }
|
||
|
||
/* Effects */
|
||
.shadow-lg { box-shadow: 0 10px 25px rgba(15, 23, 42, 0.15); }
|
||
.transition-colors { transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease; }
|
||
|
||
/* Flex / Grid */
|
||
.flex { display: flex; }
|
||
.flex-col { flex-direction: column; }
|
||
.flex-row { flex-direction: row; }
|
||
.flex-1 { flex: 1 1 0%; }
|
||
.items-center { align-items: center; }
|
||
.items-start { align-items: flex-start; }
|
||
.justify-between { justify-content: space-between; }
|
||
.justify-center { justify-content: center; }
|
||
.gap-2 { gap: 0.5rem; }
|
||
.gap-0\.5 { gap: 0.125rem; }
|
||
.grid { display: grid; }
|
||
.grid-cols-12 { grid-template-columns: repeat(12, minmax(0, 1fr)); }
|
||
.col-span-4 { grid-column: span 4 / span 4; }
|
||
.col-span-6 { grid-column: span 6 / span 6; }
|
||
.col-span-12 { grid-column: span 12 / span 12; }
|
||
|
||
/* Positioning */
|
||
.block { display: block; }
|
||
.relative { position: relative; }
|
||
.absolute { position: absolute; }
|
||
.sticky { position: sticky; }
|
||
.left-0 { left: 0; }
|
||
.top-0 { top: 0; }
|
||
.top-6 { top: 1.5rem; }
|
||
.top-full { top: 100%; }
|
||
.z-10 { z-index: 10; }
|
||
.z-20 { z-index: 20; }
|
||
.z-50 { z-index: 50; }
|
||
|
||
/* Sizing */
|
||
.w-full { width: 100%; }
|
||
.w-auto { width: auto; }
|
||
.w-64 { width: 16rem; }
|
||
.w-5 { width: 1.25rem; }
|
||
.h-5 { height: 1.25rem; }
|
||
.h-6 { height: 1.5rem; }
|
||
.h-auto { height: auto; }
|
||
.min-w-0 { min-width: 0; }
|
||
.max-w-\[1200px\] { max-width: 1200px; }
|
||
.max-w-\[1600px\] { max-width: 1600px; }
|
||
.max-h-96 { max-height: 24rem; }
|
||
.min-h-\[3.5rem\] { min-height: 3.5rem; }
|
||
.overflow-auto { overflow: auto; }
|
||
|
||
/* Spacing */
|
||
.p-2 { padding: 0.5rem; }
|
||
.px-0 { padding-left: 0; padding-right: 0; }
|
||
.px-1 { padding-left: 0.25rem; padding-right: 0.25rem; }
|
||
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||
.px-1\.5 { padding-left: 0.375rem; padding-right: 0.375rem; }
|
||
.py-0 { padding-top: 0; padding-bottom: 0; }
|
||
.py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; }
|
||
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||
.py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
|
||
.pt-1 { padding-top: 0.25rem; }
|
||
.pt-5 { padding-top: 1.25rem; }
|
||
.pb-2 { padding-bottom: 0.5rem; }
|
||
.pb-3 { padding-bottom: 0.75rem; }
|
||
.mt-1 { margin-top: 0.25rem; }
|
||
.mt-2 { margin-top: 0.5rem; }
|
||
.mb-1 { margin-bottom: 0.25rem; }
|
||
.my-1 { margin-top: 0.25rem; margin-bottom: 0.25rem; }
|
||
.gap-x-6 { column-gap: 1.5rem; }
|
||
.gap-y-3 { row-gap: 0.75rem; }
|
||
|
||
/* Drag-hover utilities (used by drop zone handlers) */
|
||
.ring-2 { box-shadow: 0 0 0 2px var(--tw-ring-color, rgba(59, 130, 246, 0.5)); }
|
||
.ring-blue-400 { --tw-ring-color: rgba(96, 165, 250, 0.7); }
|
||
.bg-blue-50 { background-color: #eff6ff; }
|
||
|
||
/* Validation error state (applied via JS in validation.js) */
|
||
.ring-red-400 { box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.5); }
|
||
.border-red-500 { border-color: #ef4444 !important; }
|
||
|
||
/* Hover & focus states */
|
||
.hover\:bg-gray-50:hover { background-color: #f9fafb; }
|
||
.hover\:bg-gray-100:hover { background-color: #f3f4f6; }
|
||
.hover\:underline:hover { text-decoration: underline; }
|
||
.focus\:outline-none:focus { outline: none; }
|
||
.focus\:border-blue-400:focus { border-color: #60a5fa; }
|
||
.focus\:ring-1:focus { box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.35); }
|
||
.focus\:ring-blue-400:focus { box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.45); }
|
||
.focus\:bg-white:focus { background-color: #ffffff; }
|
||
.disabled\:pointer-events-none:disabled { pointer-events: none; }
|
||
|
||
/* Table helpers */
|
||
.table-auto { table-layout: auto; }
|
||
.border-collapse { border-collapse: collapse; }
|
||
|
||
/* Print helpers */
|
||
@media print {
|
||
.print\:hidden { display: none !important; }
|
||
}
|
||
|
||
@media print {
|
||
body {
|
||
background: none;
|
||
padding: 0;
|
||
}
|
||
|
||
.page-container {
|
||
width: 100%;
|
||
margin: 0;
|
||
padding: 0;
|
||
min-height: 0;
|
||
box-shadow: none;
|
||
border: none;
|
||
}
|
||
|
||
@page {
|
||
margin: 0.375in;
|
||
size: 8.5in 11in;
|
||
}
|
||
|
||
.table-wrapper {
|
||
overflow: visible !important;
|
||
max-height: none !important;
|
||
}
|
||
|
||
thead th {
|
||
position: static !important;
|
||
top: auto !important;
|
||
}
|
||
|
||
/* Hide interactive-only elements in print */
|
||
.table-filter-input,
|
||
.logo-placeholder,
|
||
.drop-zone-label {
|
||
display: none !important;
|
||
}
|
||
|
||
|
||
/* Adjust table header caption spacing when filters are hidden */
|
||
.table-header__caption {
|
||
display: block;
|
||
padding: 0.25rem 0;
|
||
}
|
||
|
||
table {
|
||
width: 100% !important;
|
||
table-layout: fixed !important;
|
||
border: 1px solid #999 !important;
|
||
border-collapse: collapse !important;
|
||
}
|
||
|
||
th {
|
||
background-color: #e0e0e0 !important;
|
||
-webkit-print-color-adjust: exact;
|
||
print-color-adjust: exact;
|
||
border-left: 1px solid #999 !important;
|
||
border-right: 1px solid #999 !important;
|
||
border-bottom: 1px solid #999 !important;
|
||
border-top: none !important;
|
||
}
|
||
|
||
td {
|
||
border-left: 1px solid #999 !important;
|
||
border-right: 1px solid #999 !important;
|
||
border-top: 1px solid #999 !important;
|
||
border-bottom: 1px solid #999 !important;
|
||
}
|
||
|
||
tr.self-entry td {
|
||
background-color: #f3f4f6 !important;
|
||
-webkit-print-color-adjust: exact;
|
||
print-color-adjust: exact;
|
||
}
|
||
|
||
/* Fixed column widths for print */
|
||
thead th:nth-child(1),
|
||
tbody td:nth-child(1) {
|
||
width: 18% !important;
|
||
}
|
||
|
||
thead th:nth-child(2),
|
||
tbody td:nth-child(2) {
|
||
width: 30% !important;
|
||
}
|
||
|
||
thead th:nth-child(3),
|
||
tbody td:nth-child(3) {
|
||
width: 10% !important;
|
||
}
|
||
|
||
thead th:nth-child(4),
|
||
tbody td:nth-child(4) {
|
||
width: 8% !important;
|
||
}
|
||
|
||
thead th:nth-child(5),
|
||
tbody td:nth-child(5) {
|
||
width: 6% !important;
|
||
}
|
||
|
||
thead th:nth-child(6),
|
||
tbody td:nth-child(6) {
|
||
width: 8% !important;
|
||
}
|
||
|
||
thead th:nth-child(7),
|
||
tbody td:nth-child(7) {
|
||
width: 20% !important;
|
||
}
|
||
|
||
/* Ensure text wraps properly in print */
|
||
tbody td {
|
||
word-wrap: break-word;
|
||
overflow-wrap: break-word;
|
||
}
|
||
|
||
/* SHA256 column - allow breaking */
|
||
tbody td:nth-child(7) {
|
||
word-break: break-all;
|
||
font-size: 7pt !important;
|
||
}
|
||
|
||
/* From field: show rendered mailto link, hide input */
|
||
#from { display: none !important; }
|
||
.from-render {
|
||
display: block !important;
|
||
}
|
||
.from-mailto {
|
||
color: #2563eb !important;
|
||
-webkit-print-color-adjust: exact;
|
||
print-color-adjust: exact;
|
||
}
|
||
|
||
/* To field: show rendered mailto links, hide input */
|
||
#to { display: none !important; }
|
||
.to-render {
|
||
display: block !important;
|
||
}
|
||
.to-mailto {
|
||
color: #2563eb !important;
|
||
-webkit-print-color-adjust: exact;
|
||
print-color-adjust: exact;
|
||
}
|
||
}
|
||
|
||
</style>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>ZDDC Transmittal</title>
|
||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
|
||
</head>
|
||
|
||
<body class="font-sans text-gray-900">
|
||
<div class="app-header print:hidden" data-no-disable="true">
|
||
<div class="split-button" id="bottom-menu" hidden>
|
||
<button id="bottom-toggle" type="button" class="btn btn-primary split-button__toggle" data-no-disable="true" aria-haspopup="true" aria-expanded="false">▾</button>
|
||
<button id="bottom-primary" type="button" class="btn btn-primary" data-no-disable="true">Publish</button>
|
||
<div class="dropdown-menu hidden" role="menu" id="bottom-dropdown"></div>
|
||
</div>
|
||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||
<div class="header-title-group">
|
||
<span class="app-header__title">ZDDC Transmittal</span>
|
||
<span class="build-timestamp">v0.0.2</span>
|
||
</div>
|
||
<div class="app-header__spacer"></div>
|
||
<div class="app-header__icons">
|
||
<button type="button" id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||
<button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
|
||
</div>
|
||
</div>
|
||
<div class="page-container">
|
||
<form id="transmittal-form">
|
||
<input type="hidden" id="mode" value="edit">
|
||
<input type="hidden" id="published" value="false">
|
||
<header class="page-header flex flex-col gap-2 p-2 max-w-[1600px] relative">
|
||
<div class="logo-row">
|
||
<div class="logo-cell" id="left-logo-cell" data-drop-zone="logo-left">
|
||
<img id="left-logo" class="logo-img" alt="Sender Logo">
|
||
<span class="logo-placeholder">Drop sender logo (optional)</span>
|
||
<span class="drop-zone-label">Drop sender logo</span>
|
||
</div>
|
||
<div class="title-area">
|
||
<div class="type-combo" id="type-combo">
|
||
<input type="hidden" name="type" id="type" value="Transmittal">
|
||
<span id="type-display" role="combobox" contenteditable="true" class="type-display w-full text-2xl font-bold bg-transparent border-0 p-0 focus:outline-none text-center" data-placeholder="Type">Transmittal</span>
|
||
<div class="type-combo__menu hidden" id="type-menu" role="listbox">
|
||
<button type="button" class="type-combo__option" role="option" data-value="Transmittal">Transmittal</button>
|
||
<button type="button" class="type-combo__option" role="option" data-value="Submittal">Submittal</button>
|
||
</div>
|
||
</div>
|
||
<input type="text" name="title" id="title" placeholder="Title" class="w-full text-base italic bg-transparent border-0 p-0 focus:outline-none disabled:pointer-events-none text-center" value="">
|
||
</div>
|
||
<div class="logo-cell" id="right-logo-cell" data-drop-zone="logo-right">
|
||
<img id="right-logo" class="logo-img" alt="Receiver Logo">
|
||
<span class="logo-placeholder">Drop receiver logo (optional)</span>
|
||
<span class="drop-zone-label">Drop receiver logo</span>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
<div id="header-info" data-drop-zone="header">
|
||
<div class="drop-zone-label">Drop HTML or JSON to import transmittal data</div>
|
||
<div class="header-names w-full bg-gray-50 border border-gray-100 rounded-md px-3 py-1.5 flex flex-col gap-0.5">
|
||
<h2 id="owner-name" class="text-xl font-semibold" data-placeholder="Owner Name"></h2>
|
||
<h2 id="project-name" class="text-lg font-semibold" data-placeholder="Project Name"></h2>
|
||
<div id="project-number" class="text-sm text-gray-700" data-placeholder="Project Number"></div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-12 gap-x-6 gap-y-3 items-start">
|
||
<!-- Row 1: Tracking Number (A), Date (B) -->
|
||
<div class="col-span-6">
|
||
<div class="relative">
|
||
<input type="text" name="tracking-number" id="tracking-number" placeholder="" class="w-full min-w-0 bg-white border border-gray-300 rounded px-2 pt-5 pb-2 text-[12px] font-mono focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400">
|
||
<label for="tracking-number" class="absolute left-2 -top-2 bg-white px-1 text-[10px] text-gray-700 font-semibold pointer-events-none">Tracking Number</label>
|
||
</div>
|
||
</div>
|
||
<div class="col-span-6">
|
||
<div class="relative">
|
||
<input type="text" name="date" id="date" placeholder="YYYY-MM-DD" class="w-full min-w-0 bg-white border border-gray-300 rounded px-2 pt-5 pb-2 text-[12px] font-mono focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400">
|
||
<label for="date" class="absolute left-2 -top-2 bg-white px-1 text-[10px] text-gray-700 font-semibold pointer-events-none">Date</label>
|
||
<input type="date" id="date-picker-hidden" style="position:absolute;width:0;height:0;padding:0;border:0;overflow:hidden;clip:rect(0,0,0,0);" tabindex="-1" aria-hidden="true">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Row 2: From | Purpose (+ Response Due for Submittals) -->
|
||
<div class="col-span-6">
|
||
<div class="relative">
|
||
<input type="text" name="from" id="from" placeholder="" class="w-full min-w-0 bg-white border border-gray-300 rounded px-2 pt-5 pb-2 text-[12px] font-mono focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400">
|
||
<div id="from-render" class="from-render hidden"></div>
|
||
<label for="from" class="absolute left-2 -top-2 bg-white px-1 text-[10px] text-gray-700 font-semibold pointer-events-none">From</label>
|
||
</div>
|
||
</div>
|
||
<div class="col-span-6">
|
||
<div class="flex gap-x-6" id="purpose-group">
|
||
<div class="relative flex-1">
|
||
<input type="text" name="purpose" id="purpose" placeholder="" class="w-full min-w-0 bg-white border border-gray-300 rounded px-2 pt-5 pb-2 text-[12px] font-mono focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400">
|
||
<label for="purpose" class="absolute left-2 -top-2 bg-white px-1 text-[10px] text-gray-700 font-semibold pointer-events-none">Purpose</label>
|
||
</div>
|
||
<div class="relative flex-1 hidden" id="response-due-wrapper">
|
||
<input type="text" name="response-due" id="response-due" placeholder="YYYY-MM-DD" class="w-full min-w-0 bg-white border border-gray-300 rounded px-2 pt-5 pb-2 text-[12px] font-mono focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400">
|
||
<label for="response-due" class="absolute left-2 -top-2 bg-white px-1 text-[10px] text-gray-700 font-semibold pointer-events-none">Response Due</label>
|
||
<input type="date" id="response-due-picker-hidden" style="position:absolute;width:0;height:0;padding:0;border:0;overflow:hidden;clip:rect(0,0,0,0);" tabindex="-1" aria-hidden="true">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Row 3: To full width -->
|
||
<div class="col-span-12">
|
||
<div class="relative">
|
||
<input type="text" name="to" id="to" placeholder="" class="w-full min-w-0 bg-white border border-gray-300 rounded px-2 pt-5 pb-2 text-[12px] font-mono focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400">
|
||
<div id="to-render" class="to-render hidden"></div>
|
||
<label for="to" class="absolute left-2 -top-2 bg-white px-1 text-[10px] text-gray-700 font-semibold pointer-events-none">To</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Row 4: Subject full width -->
|
||
<div class="col-span-12">
|
||
<div class="relative">
|
||
<input type="text" name="subject" id="subject" placeholder="" class="w-full min-w-0 bg-white border border-gray-300 rounded px-2 pt-5 pb-2 text-[12px] font-bold focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400" />
|
||
<label for="subject" class="absolute left-2 -top-2 bg-white px-1 text-[10px] text-gray-700 font-semibold pointer-events-none">Subject</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Row 5: Remarks full width (Markdown) -->
|
||
<div class="col-span-12">
|
||
<div class="relative w-full">
|
||
<div id="remarks-wrapper" class="w-full">
|
||
<textarea id="remarks" name="remarks" placeholder="Remarks" aria-label="Remarks" class="px-2 pt-1 pb-3 bg-white border-0 rounded-none text-[12px] w-full min-h-[3.5rem] leading-snug resize-y"></textarea>
|
||
<div id="remarks-render-container" class="w-full p-2 overflow-auto">
|
||
<div id="remarks-render" class="text-sm"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="p-2 max-w-[1600px]">
|
||
|
||
<!-- Step: Integrity — "Is this verified?" -->
|
||
<div class="workflow-step" id="integrity-section">
|
||
<h3 class="workflow-step__label">
|
||
Integrity
|
||
<span id="signature-status-static" class="workflow-badge workflow-badge--warn" data-hydrate-hide="true">Not Validated (requires JavaScript)</span>
|
||
</h3>
|
||
<div id="integrity-body" class="integrity-body">
|
||
<div id="digest-display"></div>
|
||
<div id="signatures-list"></div>
|
||
<button id="add-signature-btn" type="button" class="btn btn-secondary btn-sm hidden print:hidden">Add Signature</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- file table goes here (tracking number, title, revision, status) -->
|
||
<div id="file-table-drop-zone" data-drop-zone="file-table">
|
||
<div class="drop-zone-label">Drop folder to scan / verify Drop HTML or JSON to import</div>
|
||
<div class="table-wrapper mt-2">
|
||
<table class="table-auto text-[10px] w-full">
|
||
<thead>
|
||
<tr>
|
||
<th class="bg-gray-100 text-center px-2 py-1 sticky top-0 z-20 whitespace-nowrap"><span class="table-header__caption">#</span></th>
|
||
<th class="bg-gray-100 text-left px-2 py-1 sticky top-0 z-20 whitespace-nowrap">
|
||
<label class="table-header">
|
||
<span class="table-header__caption">TRACKING NUMBER</span>
|
||
<input type="text" data-no-disable="true" data-filter-field="trackingNumber" class="column-filter" placeholder="filter" inputmode="text" spellcheck="false" aria-label="Filter by tracking number">
|
||
</label>
|
||
</th>
|
||
<th class="bg-gray-100 text-left px-2 py-1 sticky top-0 z-20 w-full">
|
||
<label class="table-header">
|
||
<span class="table-header__caption">TITLE</span>
|
||
<input type="text" data-no-disable="true" data-filter-field="title" class="column-filter" placeholder="filter" inputmode="text" spellcheck="false" aria-label="Filter by title">
|
||
</label>
|
||
</th>
|
||
<th class="bg-gray-100 text-center px-2 py-1 sticky top-0 z-20 whitespace-nowrap">
|
||
<label class="table-header">
|
||
<span class="table-header__caption">REVISION</span>
|
||
<input type="text" data-no-disable="true" data-filter-field="revision" class="column-filter" placeholder="filter" inputmode="text" spellcheck="false" aria-label="Filter by revision">
|
||
</label>
|
||
</th>
|
||
<th class="bg-gray-100 text-center px-2 py-1 sticky top-0 z-20 whitespace-nowrap">
|
||
<label class="table-header">
|
||
<span class="table-header__caption">STATUS</span>
|
||
<input type="text" data-no-disable="true" data-filter-field="status" class="column-filter" placeholder="filter" inputmode="text" spellcheck="false" aria-label="Filter by status">
|
||
</label>
|
||
</th>
|
||
<th class="bg-gray-100 text-center px-2 py-1 sticky top-0 z-20 whitespace-nowrap">
|
||
<label class="table-header">
|
||
<span class="table-header__caption">EXT</span>
|
||
<input type="text" data-no-disable="true" data-filter-field="extension" class="column-filter" placeholder="filter" inputmode="text" spellcheck="false" aria-label="Filter by extension">
|
||
</label>
|
||
</th>
|
||
<th class="bg-gray-100 text-right px-2 py-1 sticky top-0 z-20 whitespace-nowrap">
|
||
<label class="table-header">
|
||
<span class="table-header__caption">SIZE</span>
|
||
<input type="text" data-no-disable="true" data-filter-field="fileSize" class="column-filter" placeholder="filter" inputmode="text" spellcheck="false" aria-label="Filter by size">
|
||
</label>
|
||
</th>
|
||
<th class="bg-gray-100 text-left px-2 py-1 sticky top-0 z-20 whitespace-nowrap">
|
||
<label class="table-header">
|
||
<span class="table-header__caption">SHA256</span>
|
||
<input type="text" data-no-disable="true" data-filter-field="sha256" class="column-filter" placeholder="filter" inputmode="text" spellcheck="false" aria-label="Filter by SHA256">
|
||
</label>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<footer class="page-footer print:hidden">
|
||
<div class="page-footer__inner">
|
||
<span id="data-status" class="text-gray-600 text-[10px]" aria-live="polite"></span>
|
||
<span id="selected-directory" class="workflow-dir text-[10px]"></span>
|
||
</div>
|
||
</footer>
|
||
</form>
|
||
</div>
|
||
|
||
<dialog id="publish-modal" class="modal" data-edit-only="true" aria-labelledby="publish-modal-title">
|
||
<div class="modal__header">
|
||
<h2 id="publish-modal-title" class="modal__title">Publish Transmittal</h2>
|
||
<button type="button" class="modal__close" data-modal-close aria-label="Close">×</button>
|
||
</div>
|
||
<div class="modal__body">
|
||
<div id="publish-date-row" class="publish-field">
|
||
<label for="publish-date-input" class="publish-field__label">Date</label>
|
||
<div class="publish-field__row">
|
||
<input type="text" id="publish-date-input" class="publish-field__input" placeholder="YYYY-MM-DD" pattern="\d{4}-\d{2}-\d{2}">
|
||
<span id="publish-date-warning" class="publish-field__warning" hidden></span>
|
||
</div>
|
||
</div>
|
||
<fieldset class="publish-outputs">
|
||
<legend class="publish-field__label">Output</legend>
|
||
<label class="publish-check">
|
||
<input type="checkbox" id="publish-save-folder"> Save in directory
|
||
</label>
|
||
<label class="publish-check">
|
||
<input type="checkbox" id="publish-download-html"> Download HTML
|
||
</label>
|
||
</fieldset>
|
||
</div>
|
||
<div class="modal__feedback" id="publish-modal-feedback" role="status" aria-live="polite"></div>
|
||
<div class="modal__footer">
|
||
<button type="button" class="btn btn-secondary" data-modal-close>Cancel</button>
|
||
<button type="button" class="btn btn-secondary" id="publish-draft-btn">Save Draft</button>
|
||
<button type="button" class="btn btn-secondary" id="publish-signed-btn">Publish Signed</button>
|
||
<button type="button" class="btn btn-primary" id="publish-confirm">Publish</button>
|
||
</div>
|
||
</dialog>
|
||
|
||
<dialog id="key-dialog" class="modal modal--narrow" aria-labelledby="key-dialog-title">
|
||
<div class="modal__header">
|
||
<h2 id="key-dialog-title" class="modal__title">Signing Key</h2>
|
||
<button type="button" class="modal__close" data-modal-close aria-label="Close">×</button>
|
||
</div>
|
||
<div class="modal__body">
|
||
<p class="key-dialog__desc">Select your signing key file or generate a new key pair.</p>
|
||
<div class="key-dialog__actions">
|
||
<button type="button" class="btn btn-secondary" id="key-select-file">Select Key File</button>
|
||
<button type="button" class="btn btn-secondary" id="key-generate">Generate New Key</button>
|
||
</div>
|
||
<div id="key-generate-panel" hidden>
|
||
<label class="publish-field__label" for="key-password">Password (optional)</label>
|
||
<input type="password" id="key-password" class="publish-field__input" placeholder="Leave blank for no password" autocomplete="new-password">
|
||
<label class="publish-field__label" for="key-password-confirm" style="margin-top:0.25rem;">Confirm password</label>
|
||
<input type="password" id="key-password-confirm" class="publish-field__input" placeholder="Confirm password" autocomplete="new-password">
|
||
<div class="key-dialog__actions" style="margin-top:0.5rem;">
|
||
<button type="button" class="btn btn-primary" id="key-generate-confirm">Generate & Download</button>
|
||
<button type="button" class="btn btn-secondary" id="key-generate-cancel">Back</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal__feedback" id="key-dialog-feedback" role="status" aria-live="polite"></div>
|
||
</div>
|
||
</dialog>
|
||
|
||
<dialog id="password-dialog" class="modal modal--narrow" aria-labelledby="password-dialog-title">
|
||
<div class="modal__header">
|
||
<h2 id="password-dialog-title" class="modal__title">Enter Key Password</h2>
|
||
<button type="button" class="modal__close" data-modal-close aria-label="Close">×</button>
|
||
</div>
|
||
<div class="modal__body">
|
||
<p id="password-dialog-fingerprint" class="key-dialog__desc"></p>
|
||
<input type="password" id="password-dialog-input" class="publish-field__input" placeholder="Password" autocomplete="current-password">
|
||
<div class="modal__feedback" id="password-dialog-feedback" role="status" aria-live="polite"></div>
|
||
</div>
|
||
<div class="modal__footer">
|
||
<button type="button" class="btn btn-secondary" data-modal-close>Cancel</button>
|
||
<button type="button" class="btn btn-primary" id="password-dialog-ok">Unlock</button>
|
||
</div>
|
||
</dialog>
|
||
|
||
<!-- Help panel (non-modal slide-out) -->
|
||
<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</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 a Transmittal?</h3>
|
||
<p>A transmittal is a cover sheet that travels with a set of files. It records <em>what</em> was sent, <em>to whom</em>, and <em>when</em>. This tool produces a single self-contained HTML file that serves as both the cover sheet and a cryptographic proof of what was delivered.</p>
|
||
<p><strong>How it works:</strong> Each file in the transmittal is fingerprinted with a SHA-256 hash — a value computed from the file’s contents. Change even one byte and the hash changes, so a matching hash proves the file is unaltered. The transmittal then hashes the entire payload (header, file list, and all per-file hashes) into a single <em>digest</em>. Optionally, one or more people can digitally sign that digest.</p>
|
||
|
||
<h3>Typical Workflow</h3>
|
||
<ol>
|
||
<li><strong>Fill in the header</strong> — tracking number, date, recipient, project, etc.</li>
|
||
<li>Optionally <em>Save Draft</em> to create a reusable template.</li>
|
||
<li><strong>Add files</strong> — paste rows from a spreadsheet, or use <em>Scan Directory</em> to read a folder and compute hashes automatically.</li>
|
||
<li><strong>Review</strong> the file table and remarks.</li>
|
||
<li>Use <em>Verify Directory</em> to confirm every listed file is present and unchanged.</li>
|
||
<li><strong>Publish</strong> — click <em>Publish</em> for an unsigned digest, or use the dropdown for <em>Publish Signed</em> (with a signing key) or <em>Save Draft</em>. The output is saved to the working directory, or downloaded if no directory is selected.</li>
|
||
<li><strong>Distribute</strong> — send the HTML alongside the actual files.</li>
|
||
</ol>
|
||
|
||
<h3>Signing Keys</h3>
|
||
<p>Signing keys are stored in <code>.zddc-key</code> files. When generating a new key, you can optionally protect it with a password. Password-protected keys require the password each time they are used to sign.</p>
|
||
<p>Keys without a password work immediately, like an unprotected SSH key.</p>
|
||
|
||
<h3>Verification Levels</h3>
|
||
<p>A published transmittal provides up to four escalating levels of integrity protection. Higher levels guard against increasingly sophisticated threats:</p>
|
||
<ol>
|
||
<li><strong>Static HTML</strong> — A human-readable record of everything sent. Works without JavaScript (e.g. on a SharePoint site). No cryptographic protection.</li>
|
||
<li><strong>Digest check</strong> — With JavaScript, the tool recomputes the SHA-256 digest and compares it to the stored value. A match proves nothing changed since publication. Detects accidental corruption, but someone could alter both the data and the digest.</li>
|
||
<li><strong>Digital signatures</strong> — ECDSA signatures bind the digest to a signer’s private key. Any change invalidates the signature, preventing undetected tampering. Add signatures after publishing via <em>Add Signature</em>.</li>
|
||
<li><strong>Independent verification</strong> — A tampered HTML file could ship modified JavaScript that always says “Verified.” To rule this out, the recipient opens the sender’s file in their <em>own</em> trusted copy of the tool using <em>Import HTML</em>. This extracts the raw data and re-verifies it with the recipient’s own code. Use this level for anything that matters.</li>
|
||
</ol>
|
||
<p class="text-sm text-gray-500">Level 4 assumes the recipient’s tool is trustworthy (downloaded from a known source or built from source). A reference instance is at <a href="https://zddc.varasys.io/releases/transmittal_stable.html" target="_blank" rel="noopener">zddc.varasys.io</a>.</p>
|
||
|
||
<h3>Menu Actions</h3>
|
||
<p>Actions available from the dropdown button. <span class="help-badge help-badge--draft">draft</span> items appear only while editing. <span class="help-badge help-badge--published">published</span> items appear only after publishing. Unmarked items appear in both modes.</p>
|
||
|
||
<dl>
|
||
<dt>Save Draft <span class="help-badge help-badge--draft">draft</span></dt>
|
||
<dd>Downloads the current state as an HTML file. Reopen it later to continue editing, or use it as a template for new transmittals.</dd>
|
||
|
||
<dt>Revise <span class="help-badge help-badge--published">published</span></dt>
|
||
<dd>Unlocks a published transmittal back into an editable draft.</dd>
|
||
|
||
<dt>Add Signature <span class="help-badge help-badge--published">published</span></dt>
|
||
<dd>Signs the digest with a new ECDSA key and downloads the updated file. Multiple people can sign sequentially.</dd>
|
||
|
||
<dt>Acknowledge Receipt <span class="help-badge help-badge--published">published</span></dt>
|
||
<dd>Same as Add Signature, but labeled “Received By” in the signature list — used by the recipient to confirm delivery.</dd>
|
||
</dl>
|
||
|
||
<h4>Table Clipboard</h4>
|
||
<dl>
|
||
<dt>Paste New Rows <span class="help-badge help-badge--draft">draft</span></dt>
|
||
<dd>Replaces the file table with tab-separated data from the clipboard (e.g. copied from Excel).</dd>
|
||
<dt>Paste Append Rows <span class="help-badge help-badge--draft">draft</span></dt>
|
||
<dd>Appends clipboard rows to the existing file table.</dd>
|
||
<dt>Copy Table</dt>
|
||
<dd>Copies the file table as tab-separated text for pasting into spreadsheets.</dd>
|
||
</dl>
|
||
|
||
<h4>Data Import & Export</h4>
|
||
<dl>
|
||
<dt>Import HTML</dt>
|
||
<dd>Opens a published transmittal HTML, extracts its data, and re-verifies it with your own code (Level 4). You can also drag-and-drop an HTML or JSON file directly onto the <strong>Header</strong> or <strong>File table</strong> drop zones — drag anything over the page to reveal the zones.</dd>
|
||
<dt>Copy JSON</dt>
|
||
<dd>Copies the raw transmittal data as JSON for backup or transfer.</dd>
|
||
<dt>Paste JSON <span class="help-badge help-badge--draft">draft</span></dt>
|
||
<dd>Loads transmittal data from JSON on the clipboard.</dd>
|
||
</dl>
|
||
|
||
<h4>Drag-and-Drop Zones</h4>
|
||
<p>Drag any file or folder over the page to reveal labelled drop zones. Zones that can accept your data are highlighted in blue; others are dimmed.</p>
|
||
<dl>
|
||
<dt>Sender / Receiver logo zones</dt>
|
||
<dd>Top-left and top-right areas. Accept image files. Edit mode only.</dd>
|
||
<dt>Header zone <span class="help-badge help-badge--draft">draft</span></dt>
|
||
<dd>The form header area. Accepts a transmittal HTML or JSON file to import all fields.</dd>
|
||
<dt>File table zone</dt>
|
||
<dd>The document list area. Accepts a folder (triggers Scan or Verify), or a transmittal HTML/JSON file.</dd>
|
||
</dl>
|
||
|
||
<h4>Directory Operations</h4>
|
||
<p class="text-sm text-gray-500">Requires a Chromium-based desktop browser (File System Access API).</p>
|
||
<dl>
|
||
<dt>Scan Directory <span class="help-badge help-badge--draft">draft</span></dt>
|
||
<dd>Picks a local folder, lists every file, and computes SHA-256 hashes to populate the file table.</dd>
|
||
<dt>Verify Directory</dt>
|
||
<dd>Picks a local folder and checks that each file on disk matches its hash in the transmittal.</dd>
|
||
</dl>
|
||
|
||
<h4>Other</h4>
|
||
<dl>
|
||
<dt>Create Index</dt>
|
||
<dd>Scans transmittal folders and builds a <code>.archive</code> directory of small HTML redirects keyed by tracking number, revision, and hash — enabling direct links between documents in a file-based archive.</dd>
|
||
<dt>Reset</dt>
|
||
<dd>Clears everything and restores the blank template.</dd>
|
||
</dl>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Single source of truth for all data; replace this block to swap content -->
|
||
<!-- Hydration: Populate static content from JSON on publish -->
|
||
<script id="transmittal-data" type="application/json">
|
||
{
|
||
"envelope": {
|
||
"version": 1,
|
||
"digestAlgorithm": "SHA-256",
|
||
"digest": "",
|
||
"digestedAt": "",
|
||
"signatureAlgorithm": "ECDSA-P256-SHA256",
|
||
"signatures": []
|
||
},
|
||
"payload": {
|
||
"version": 1,
|
||
"type": "Transmittal",
|
||
"title": "",
|
||
"client": "",
|
||
"project": "",
|
||
"projectNumber": "",
|
||
"date": "",
|
||
"trackingNumber": "",
|
||
"from": "",
|
||
"to": "",
|
||
"purpose": "",
|
||
"responseDue": "",
|
||
"subject": "",
|
||
"remarks": "",
|
||
"files": []
|
||
},
|
||
"presentation": {
|
||
"leftLogo": "",
|
||
"rightLogo": "",
|
||
"theme": "default",
|
||
"customCss": ""
|
||
}
|
||
}
|
||
</script>
|
||
<script>
|
||
/**
|
||
* 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));
|
||
|
||
/**
|
||
* ZDDC — shared SHA-256 helpers
|
||
*
|
||
* Attaches to window.zddc.crypto. Must load AFTER shared/zddc.js (which creates
|
||
* the window.zddc object).
|
||
*
|
||
* Exports:
|
||
* zddc.crypto.sha256Hex(buffer) → Promise<string> hex digest of ArrayBuffer/Uint8Array
|
||
* zddc.crypto.sha256String(str) → Promise<string> hex digest of UTF-8 encoded string
|
||
* zddc.crypto.sha256File(file, onProgress?) → Promise<string>
|
||
* chunked streaming digest for File/Blob; for files >= 4 MB, streams 2 MB chunks
|
||
* and invokes onProgress(loaded, total) every ~8 MB.
|
||
* zddc.crypto.bytesToHex(buffer) → string (hex of ArrayBuffer/Uint8Array, no digest)
|
||
*
|
||
* Throws if Web Crypto SubtleCrypto is not available.
|
||
*/
|
||
(function (root) {
|
||
'use strict';
|
||
|
||
if (!root.zddc) {
|
||
throw new Error('shared/hash.js: window.zddc must be loaded first');
|
||
}
|
||
|
||
var HASH_CHUNK_SIZE = 2 * 1024 * 1024; // 2 MB
|
||
|
||
function requireSubtle() {
|
||
if (!root.crypto || !root.crypto.subtle || typeof root.crypto.subtle.digest !== 'function') {
|
||
throw new Error('Web Crypto SubtleCrypto is required');
|
||
}
|
||
}
|
||
|
||
function bytesToHex(buffer) {
|
||
return Array.from(new Uint8Array(buffer), function (byte) {
|
||
return byte.toString(16).padStart(2, '0');
|
||
}).join('');
|
||
}
|
||
|
||
async function sha256Hex(buffer) {
|
||
requireSubtle();
|
||
var input = (buffer instanceof Uint8Array) ? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) : buffer;
|
||
var hash = await root.crypto.subtle.digest('SHA-256', input);
|
||
return bytesToHex(hash);
|
||
}
|
||
|
||
async function sha256String(str) {
|
||
requireSubtle();
|
||
var bytes = new TextEncoder().encode(str);
|
||
var hash = await root.crypto.subtle.digest('SHA-256', bytes);
|
||
return bytesToHex(hash);
|
||
}
|
||
|
||
async function sha256File(file, onProgress) {
|
||
requireSubtle();
|
||
// Single-shot for small files or environments without ReadableStream
|
||
if (file.size < HASH_CHUNK_SIZE * 2 || typeof file.stream !== 'function') {
|
||
if (onProgress) { onProgress(file.size, file.size); }
|
||
var buf = await file.arrayBuffer();
|
||
var hash = await root.crypto.subtle.digest('SHA-256', buf);
|
||
return bytesToHex(hash);
|
||
}
|
||
// Chunked streaming for large files
|
||
var reader = file.stream().getReader();
|
||
var loaded = 0;
|
||
var chunks = [];
|
||
var yieldCounter = 0;
|
||
while (true) {
|
||
var result = await reader.read();
|
||
if (result.done) { break; }
|
||
chunks.push(result.value);
|
||
loaded += result.value.byteLength;
|
||
yieldCounter++;
|
||
if (onProgress && yieldCounter % 4 === 0) {
|
||
onProgress(loaded, file.size);
|
||
await new Promise(function (r) { setTimeout(r, 0); });
|
||
}
|
||
}
|
||
var total = new Uint8Array(loaded);
|
||
var offset = 0;
|
||
for (var i = 0; i < chunks.length; i++) {
|
||
total.set(chunks[i], offset);
|
||
offset += chunks[i].byteLength;
|
||
}
|
||
var digest = await root.crypto.subtle.digest('SHA-256', total.buffer);
|
||
if (onProgress) { onProgress(file.size, file.size); }
|
||
return bytesToHex(digest);
|
||
}
|
||
|
||
root.zddc.crypto = {
|
||
sha256Hex: sha256Hex,
|
||
sha256String: sha256String,
|
||
sha256File: sha256File,
|
||
bytesToHex: bytesToHex,
|
||
};
|
||
})(typeof window !== 'undefined' ? window : globalThis);
|
||
|
||
/**
|
||
* 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();
|
||
}
|
||
}());
|
||
|
||
(function (global) {
|
||
'use strict';
|
||
|
||
if (global.transmittalApp) {
|
||
return;
|
||
}
|
||
|
||
const app = {
|
||
state: {
|
||
mode: 'edit',
|
||
published: false,
|
||
dirty: false
|
||
},
|
||
data: {
|
||
files: [],
|
||
selectedDirHandle: null,
|
||
selectedDirName: ''
|
||
},
|
||
constants: {
|
||
viewableExts: ['pdf', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'txt', 'html', 'htm', 'md'],
|
||
digestAlgorithm: 'SHA-256',
|
||
signatureAlgorithm: 'ECDSA-P256-SHA256',
|
||
SELF_HASH: 'self:payload-digest'
|
||
},
|
||
modules: {},
|
||
initCallbacks: [],
|
||
registerInit(fn) {
|
||
if (typeof fn === 'function') {
|
||
app.initCallbacks.push(fn);
|
||
}
|
||
},
|
||
dirtyListeners: [],
|
||
onDirty(fn) {
|
||
if (typeof fn === 'function') {
|
||
app.dirtyListeners.push(fn);
|
||
}
|
||
},
|
||
markDirty() {
|
||
app.state.dirty = true;
|
||
app.dirtyListeners.forEach(function (fn) {
|
||
try { fn(); } catch (err) { console.error('[transmittal] dirtyListener error', err); }
|
||
});
|
||
},
|
||
ready(fn) {
|
||
if (typeof fn !== 'function') {
|
||
return;
|
||
}
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', fn, { once: true });
|
||
} else {
|
||
fn();
|
||
}
|
||
},
|
||
start() {
|
||
if (app._started) {
|
||
return;
|
||
}
|
||
app._started = true;
|
||
const run = function () {
|
||
app.initCallbacks.forEach(function (fn) {
|
||
try {
|
||
fn();
|
||
} catch (err) {
|
||
console.error('[transmittal] init error', err);
|
||
}
|
||
});
|
||
};
|
||
app.ready(run);
|
||
}
|
||
};
|
||
|
||
global.transmittalApp = app;
|
||
})(window);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
/**
|
||
* Creates a reactive state object using Proxy.
|
||
* When properties change, all registered subscribers are notified.
|
||
*
|
||
* @param {Object} initialState - Initial state values
|
||
* @returns {Object} Reactive state proxy with subscribe/unsubscribe methods
|
||
*/
|
||
function createReactiveState(initialState) {
|
||
const subscribers = new Set();
|
||
const state = { ...initialState };
|
||
|
||
const handler = {
|
||
get(target, property) {
|
||
// Expose subscription methods
|
||
if (property === 'subscribe') {
|
||
return function(callback) {
|
||
if (typeof callback === 'function') {
|
||
subscribers.add(callback);
|
||
}
|
||
return function unsubscribe() {
|
||
subscribers.delete(callback);
|
||
};
|
||
};
|
||
}
|
||
|
||
if (property === 'unsubscribeAll') {
|
||
return function() {
|
||
subscribers.clear();
|
||
};
|
||
}
|
||
|
||
// Return the actual property value
|
||
return target[property];
|
||
},
|
||
|
||
set(target, property, value) {
|
||
const oldValue = target[property];
|
||
|
||
// Only notify if value actually changed
|
||
if (oldValue !== value) {
|
||
target[property] = value;
|
||
|
||
// Notify all subscribers
|
||
subscribers.forEach(function(callback) {
|
||
try {
|
||
callback(property, value, oldValue);
|
||
} catch (err) {
|
||
console.error('[reactive] Subscriber error:', err);
|
||
}
|
||
});
|
||
}
|
||
|
||
return true;
|
||
}
|
||
};
|
||
|
||
return new Proxy(state, handler);
|
||
}
|
||
|
||
app.createReactiveState = createReactiveState;
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const dom = app.dom = app.dom || {};
|
||
|
||
dom.qs = function (selector) {
|
||
return document.querySelector(selector);
|
||
};
|
||
|
||
dom.qsa = function (selector) {
|
||
return Array.from(document.querySelectorAll(selector));
|
||
};
|
||
|
||
dom.show = function (element, shouldShow) {
|
||
if (!element) {
|
||
return;
|
||
}
|
||
element.hidden = !shouldShow;
|
||
};
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const util = app.util = app.util || {};
|
||
|
||
util.hasCrypto = function hasCrypto() {
|
||
return !!(window.crypto && window.crypto.subtle && typeof window.crypto.subtle.digest === 'function');
|
||
};
|
||
|
||
util.canonicalStringify = function canonicalStringify(input) {
|
||
if (input === null) {
|
||
return 'null';
|
||
}
|
||
const type = typeof input;
|
||
if (type === 'number' || type === 'boolean' || type === 'string') {
|
||
return JSON.stringify(input);
|
||
}
|
||
if (Array.isArray(input)) {
|
||
return '[' + input.map(util.canonicalStringify).join(',') + ']';
|
||
}
|
||
const keys = Object.keys(input).sort();
|
||
return '{' + keys.map(function (key) {
|
||
return JSON.stringify(key) + ':' + util.canonicalStringify(input[key]);
|
||
}).join(',') + '}';
|
||
};
|
||
|
||
util.hashString = function hashString(str) {
|
||
return zddc.crypto.sha256String(str);
|
||
};
|
||
|
||
util.arrayBufferToHex = function arrayBufferToHex(buffer) {
|
||
return zddc.crypto.bytesToHex(buffer);
|
||
};
|
||
|
||
util.base64ToArrayBuffer = function base64ToArrayBuffer(base64Value) {
|
||
if (!base64Value) {
|
||
return new ArrayBuffer(0);
|
||
}
|
||
const binaryString = atob(base64Value);
|
||
const length = binaryString.length;
|
||
const bytes = new Uint8Array(length);
|
||
for (let index = 0; index < length; index += 1) {
|
||
bytes[index] = binaryString.charCodeAt(index);
|
||
}
|
||
return bytes.buffer;
|
||
};
|
||
|
||
util.arrayBufferToBase64 = function arrayBufferToBase64(buffer) {
|
||
const bytes = new Uint8Array(buffer);
|
||
let output = '';
|
||
for (let index = 0; index < bytes.length; index += 1) {
|
||
output += String.fromCharCode(bytes[index]);
|
||
}
|
||
return btoa(output);
|
||
};
|
||
|
||
// Revision comparison delegates to shared zddc library
|
||
util.compareRevisionPriority = function compareRevisionPriority(aRevision, bRevision) {
|
||
return zddc.compareRevisions(aRevision, bRevision);
|
||
};
|
||
|
||
util.compareFilesByTrackingRevision = function compareFilesByTrackingRevision(a, b) {
|
||
const trackingA = (a && a.trackingNumber ? String(a.trackingNumber) : '').toLowerCase();
|
||
const trackingB = (b && b.trackingNumber ? String(b.trackingNumber) : '').toLowerCase();
|
||
if (trackingA < trackingB) {
|
||
return -1;
|
||
}
|
||
if (trackingA > trackingB) {
|
||
return 1;
|
||
}
|
||
|
||
const revisionCompare = util.compareRevisionPriority(a && a.revision, b && b.revision);
|
||
if (revisionCompare !== 0) {
|
||
return revisionCompare;
|
||
}
|
||
|
||
const extA = (a && a.extension ? String(a.extension) : '').toLowerCase();
|
||
const extB = (b && b.extension ? String(b.extension) : '').toLowerCase();
|
||
if (extA < extB) {
|
||
return -1;
|
||
}
|
||
if (extA > extB) {
|
||
return 1;
|
||
}
|
||
|
||
return 0;
|
||
};
|
||
|
||
util.canonicalizePublicJwk = function canonicalizePublicJwk(pk) {
|
||
if (!pk) {
|
||
return { kty: 'EC', crv: 'P-256', x: '', y: '' };
|
||
}
|
||
return {
|
||
kty: pk.kty || 'EC',
|
||
crv: pk.crv || 'P-256',
|
||
x: pk.x || '',
|
||
y: pk.y || ''
|
||
};
|
||
};
|
||
|
||
util.publicKeyFingerprint = async function publicKeyFingerprint(pk) {
|
||
try {
|
||
if (!pk) {
|
||
return '';
|
||
}
|
||
if (!util.hasCrypto()) {
|
||
return null;
|
||
}
|
||
const canonical = util.canonicalizePublicJwk(pk);
|
||
const canonicalStr = util.canonicalStringify(canonical);
|
||
const hash = await util.hashString(canonicalStr);
|
||
return (hash || '').slice(0, 12);
|
||
} catch (err) {
|
||
console.error('[transmittal] publicKeyFingerprint error', err);
|
||
return '';
|
||
}
|
||
};
|
||
|
||
util.hashFile = function hashFile(file, onProgress) {
|
||
return zddc.crypto.sha256File(file, onProgress);
|
||
};
|
||
|
||
util.escapeHtml = function escapeHtml(value) {
|
||
return String(value || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
};
|
||
|
||
util.escapeHtmlAttribute = function escapeHtmlAttribute(value) {
|
||
return String(value || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"');
|
||
};
|
||
|
||
util.downloadBlob = function downloadBlob(filename, contents, mime) {
|
||
var blob = (contents instanceof Blob)
|
||
? contents
|
||
: new Blob([contents], { type: mime || 'application/octet-stream' });
|
||
var anchor = document.createElement('a');
|
||
anchor.href = URL.createObjectURL(blob);
|
||
anchor.download = filename;
|
||
document.body.appendChild(anchor);
|
||
anchor.click();
|
||
setTimeout(function () {
|
||
URL.revokeObjectURL(anchor.href);
|
||
anchor.remove();
|
||
}, 0);
|
||
};
|
||
|
||
util.createEmptyData = function createEmptyData(date) {
|
||
return {
|
||
envelope: {
|
||
version: 1,
|
||
digestAlgorithm: app.constants.digestAlgorithm,
|
||
digest: '',
|
||
digestedAt: '',
|
||
signatureAlgorithm: app.constants.signatureAlgorithm,
|
||
signatures: []
|
||
},
|
||
payload: {
|
||
version: 1,
|
||
type: 'Transmittal',
|
||
title: '',
|
||
client: '',
|
||
project: '',
|
||
projectNumber: '',
|
||
date: date || '',
|
||
trackingNumber: '',
|
||
from: '',
|
||
to: '',
|
||
purpose: '',
|
||
responseDue: '',
|
||
subject: '',
|
||
remarks: '',
|
||
files: []
|
||
},
|
||
presentation: {
|
||
leftLogo: '',
|
||
rightLogo: '',
|
||
theme: 'default',
|
||
customCss: ''
|
||
}
|
||
};
|
||
};
|
||
|
||
util.fetchTrustedTime = function fetchTrustedTime() {
|
||
return new Date().toISOString();
|
||
};
|
||
|
||
util.formatISOWithTZ = function formatISOWithTZ(isoStr) {
|
||
if (!isoStr) { return 'Unknown'; }
|
||
var d = new Date(isoStr);
|
||
if (isNaN(d.getTime())) { return isoStr; }
|
||
try {
|
||
return d.toLocaleDateString('en-US', {
|
||
weekday: 'long',
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: 'numeric',
|
||
minute: '2-digit',
|
||
timeZoneName: 'short'
|
||
});
|
||
} catch (_) {
|
||
return d.toLocaleString();
|
||
}
|
||
};
|
||
|
||
util.signEnvelope = async function signEnvelope(envelope, jwk) {
|
||
if (!jwk || jwk.kty !== 'EC') {
|
||
throw new Error('A valid EC private key (JWK) is required to sign.');
|
||
}
|
||
var envelopeToSign = {
|
||
version: envelope.version || 1,
|
||
digestAlgorithm: envelope.digestAlgorithm || app.constants.digestAlgorithm,
|
||
digest: envelope.digest,
|
||
digestedAt: envelope.digestedAt,
|
||
signatureAlgorithm: envelope.signatureAlgorithm || app.constants.signatureAlgorithm
|
||
};
|
||
var envelopeStr = util.canonicalStringify(envelopeToSign);
|
||
var key = await window.crypto.subtle.importKey(
|
||
'jwk',
|
||
jwk,
|
||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||
false,
|
||
['sign']
|
||
);
|
||
var raw = await window.crypto.subtle.sign(
|
||
{ name: 'ECDSA', hash: { name: 'SHA-256' } },
|
||
key,
|
||
new TextEncoder().encode(envelopeStr)
|
||
);
|
||
return {
|
||
signature: util.arrayBufferToBase64(raw),
|
||
signedAt: util.fetchTrustedTime()
|
||
};
|
||
};
|
||
|
||
|
||
util.formatShortHash = function formatShortHash(hex) {
|
||
const normalized = (hex || '').trim();
|
||
if (!normalized) {
|
||
return '';
|
||
}
|
||
if (normalized.length <= 20) {
|
||
return normalized;
|
||
}
|
||
return normalized.slice(0, 12) + '\u2026' + normalized.slice(-8);
|
||
};
|
||
|
||
util.formatShortFileHash = function formatShortFileHash(hex) {
|
||
const normalized = (hex || '').trim();
|
||
if (!normalized) {
|
||
return '';
|
||
}
|
||
if (normalized.length <= 12) {
|
||
return normalized;
|
||
}
|
||
return normalized.slice(0, 6) + '\u2026' + normalized.slice(-5);
|
||
};
|
||
|
||
util.formatFileSize = function formatFileSize(bytes) {
|
||
const num = Number(bytes) || 0;
|
||
if (num === 0) {
|
||
return '0 B';
|
||
}
|
||
if (num < 1024) {
|
||
return num + ' B';
|
||
}
|
||
if (num < 1024 * 1024) {
|
||
return (num / 1024).toFixed(1) + ' KB';
|
||
}
|
||
if (num < 1024 * 1024 * 1024) {
|
||
return (num / (1024 * 1024)).toFixed(1) + ' MB';
|
||
}
|
||
return (num / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||
};
|
||
|
||
util.fileToImage = function fileToImage(file) {
|
||
return new Promise(function (resolve, reject) {
|
||
const url = URL.createObjectURL(file);
|
||
const img = new Image();
|
||
img.onload = function () {
|
||
URL.revokeObjectURL(url);
|
||
resolve(img);
|
||
};
|
||
img.onerror = function (err) {
|
||
URL.revokeObjectURL(url);
|
||
reject(err);
|
||
};
|
||
img.src = url;
|
||
});
|
||
};
|
||
|
||
util.imageFileToPngDataUrl = async function imageFileToPngDataUrl(file, maxWidth, maxHeight) {
|
||
const img = await util.fileToImage(file);
|
||
const scale = Math.min(1, maxWidth / img.width, maxHeight / img.height);
|
||
const w = Math.round(img.width * scale);
|
||
const h = Math.round(img.height * scale);
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w;
|
||
canvas.height = h;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(img, 0, 0, w, h);
|
||
return canvas.toDataURL('image/png');
|
||
};
|
||
|
||
// ── ZDDC Signing Key utilities ─────────────────────────
|
||
var KEY_FORMAT = 'zddc-signing-key-v1';
|
||
var KDF_ITERATIONS = 100000;
|
||
|
||
function deriveWrappingKey(password, salt) {
|
||
var enc = new TextEncoder();
|
||
return window.crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
|
||
.then(function (baseKey) {
|
||
return window.crypto.subtle.deriveKey(
|
||
{ name: 'PBKDF2', salt: salt, iterations: KDF_ITERATIONS, hash: 'SHA-256' },
|
||
baseKey,
|
||
{ name: 'AES-GCM', length: 256 },
|
||
false,
|
||
['encrypt', 'decrypt']
|
||
);
|
||
});
|
||
}
|
||
|
||
util.encryptPrivateKey = async function encryptPrivateKey(jwk, password, publicFingerprint) {
|
||
var plaintext = new TextEncoder().encode(JSON.stringify(jwk));
|
||
var salt = window.crypto.getRandomValues(new Uint8Array(16));
|
||
var iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||
var wrappingKey = await deriveWrappingKey(password, salt);
|
||
var ciphertext = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, wrappingKey, plaintext);
|
||
return {
|
||
format: KEY_FORMAT,
|
||
publicFingerprint: publicFingerprint || '',
|
||
encrypted: true,
|
||
kdf: 'PBKDF2-SHA256',
|
||
iterations: KDF_ITERATIONS,
|
||
salt: util.arrayBufferToBase64(salt),
|
||
iv: util.arrayBufferToBase64(iv),
|
||
ciphertext: util.arrayBufferToBase64(ciphertext)
|
||
};
|
||
};
|
||
|
||
util.decryptPrivateKey = async function decryptPrivateKey(keyData, password) {
|
||
var salt = util.base64ToArrayBuffer(keyData.salt);
|
||
var iv = util.base64ToArrayBuffer(keyData.iv);
|
||
var ciphertext = util.base64ToArrayBuffer(keyData.ciphertext);
|
||
var wrappingKey = await deriveWrappingKey(password, salt);
|
||
var plaintext = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, wrappingKey, ciphertext);
|
||
return JSON.parse(new TextDecoder().decode(plaintext));
|
||
};
|
||
|
||
util.wrapKeyFile = function wrapKeyFile(jwk, publicFingerprint) {
|
||
return {
|
||
format: KEY_FORMAT,
|
||
publicFingerprint: publicFingerprint || '',
|
||
encrypted: false,
|
||
key: jwk
|
||
};
|
||
};
|
||
|
||
util.loadKeyFile = async function loadKeyFile(text, promptForPassword) {
|
||
var data = JSON.parse(text);
|
||
// ZDDC key format
|
||
if (data.format === KEY_FORMAT) {
|
||
if (data.encrypted) {
|
||
if (typeof promptForPassword !== 'function') {
|
||
throw new Error('Password required but no prompt available.');
|
||
}
|
||
var password = await promptForPassword(data.publicFingerprint);
|
||
if (!password && password !== '') { return null; } // user cancelled
|
||
return util.decryptPrivateKey(data, password);
|
||
}
|
||
return data.key;
|
||
}
|
||
throw new Error('Unrecognized key file format. Expected a .zddc-key file.');
|
||
};
|
||
|
||
util.cloneDocumentHtml = function cloneDocumentHtml() {
|
||
// Temporarily remove inline scripts from the LIVE DOM so that
|
||
// outerHTML produces a clean string with zero script bodies.
|
||
// We never create a clone via innerHTML because the HTML parser
|
||
// would corrupt the JSON block if base64 data contains a close-script tag.
|
||
var scripts = document.querySelectorAll('script:not([src])');
|
||
var saved = [];
|
||
for (var i = 0; i < scripts.length; i++) {
|
||
var el = scripts[i];
|
||
saved.push({
|
||
el: el,
|
||
parent: el.parentNode,
|
||
next: el.nextSibling,
|
||
body: el.textContent || '',
|
||
attrs: ''
|
||
});
|
||
for (var a = 0; a < el.attributes.length; a++) {
|
||
var attr = el.attributes[a];
|
||
saved[i].attrs += ' ' + attr.name + '="' +
|
||
attr.value.replace(/&/g, '&').replace(/"/g, '"') + '"';
|
||
}
|
||
el.parentNode.removeChild(el);
|
||
}
|
||
|
||
var html = document.documentElement.outerHTML;
|
||
|
||
// Restore every script element to the live DOM immediately
|
||
for (var r = 0; r < saved.length; r++) {
|
||
saved[r].parent.insertBefore(saved[r].el, saved[r].next);
|
||
}
|
||
|
||
// Build each script tag as a safe string and insert before </body>
|
||
var scriptStrings = '';
|
||
for (var j = 0; j < saved.length; j++) {
|
||
var s = saved[j];
|
||
var safeBody = s.body;
|
||
var scriptType = (s.el.getAttribute('type') || '').toLowerCase();
|
||
if (scriptType === 'application/json') {
|
||
// Re-serialize from parsed data so we control the output.
|
||
// \u003c is valid JSON; JSON.parse converts it back to <
|
||
var jsonData = app.json.parse();
|
||
safeBody = JSON.stringify(jsonData, null, 2).replace(/</g, '\\u003c');
|
||
}
|
||
scriptStrings += '\n<script' + s.attrs + '>' + safeBody +
|
||
'</' + 'script>';
|
||
}
|
||
|
||
// Insert scripts before closing </body>
|
||
var bodyClose = html.lastIndexOf('</' + 'body>');
|
||
if (bodyClose !== -1) {
|
||
html = html.substring(0, bodyClose) + scriptStrings + '\n' +
|
||
html.substring(bodyClose);
|
||
} else {
|
||
html += scriptStrings;
|
||
}
|
||
|
||
return '<!DOCTYPE html>\n' + html;
|
||
};
|
||
|
||
/**
|
||
* Fetch the current page's own source HTML and replace only the
|
||
* transmittal-data JSON block with the supplied data object.
|
||
*
|
||
* This is the preferred save mechanism for drafts because it produces
|
||
* an exact copy of the source file with only the data changed, rather
|
||
* than a DOM snapshot that may contain stale or mutated content.
|
||
*
|
||
* @param {object} jsonData - The data object to embed as JSON.
|
||
* @returns {Promise<string>} The patched HTML string.
|
||
* @throws {Error} If the fetch fails or the JSON block is not found.
|
||
*/
|
||
util.fetchAndPatchHtml = async function fetchAndPatchHtml(jsonData) {
|
||
var response = await fetch(location.href, { cache: 'no-cache' });
|
||
if (!response.ok) {
|
||
throw new Error('fetch failed with status ' + response.status);
|
||
}
|
||
var html = await response.text();
|
||
// \u003c is valid JSON; JSON.parse converts it back to <
|
||
var jsonStr = JSON.stringify(jsonData, null, 2).replace(/</g, '\\u003c');
|
||
// Replace the transmittal-data script body. The non-greedy [\s\S]*? stops
|
||
// at the first close-script tag, which is safe because < is escaped above.
|
||
var patched = html.replace(
|
||
new RegExp(
|
||
'(<script\\b[^>]*\\bid\\s*=\\s*["\']transmittal-data["\'][^>]*>)[\\s\\S]*?(<\\/' + 'script>)',
|
||
'i'
|
||
),
|
||
'$1\n' + jsonStr + '\n$2'
|
||
);
|
||
if (patched === html) {
|
||
throw new Error('transmittal-data script block not found in fetched HTML');
|
||
}
|
||
return patched;
|
||
};
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const json = app.json = app.json || {};
|
||
|
||
const SCRIPT_ID = 'transmittal-data';
|
||
|
||
json.getScriptElement = function getScriptElement() {
|
||
return document.getElementById(SCRIPT_ID);
|
||
};
|
||
|
||
json.getRawText = function getRawText() {
|
||
const el = json.getScriptElement();
|
||
return el && typeof el.textContent === 'string' ? el.textContent : '';
|
||
};
|
||
|
||
json.setData = function setData(obj) {
|
||
const el = json.getScriptElement();
|
||
if (!el) {
|
||
return;
|
||
}
|
||
try {
|
||
el.textContent = JSON.stringify(obj, null, 2);
|
||
} catch (err) {
|
||
console.error('[transmittal] failed to serialize JSON', err);
|
||
}
|
||
};
|
||
|
||
json.parse = function parse() {
|
||
try {
|
||
const raw = json.getRawText();
|
||
if (raw && raw.trim().length) {
|
||
return JSON.parse(raw);
|
||
}
|
||
} catch (err) {
|
||
console.error('[transmittal] failed to parse JSON store', err);
|
||
}
|
||
return app.util.createEmptyData('');
|
||
};
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const dom = app.dom;
|
||
const json = app.json;
|
||
const util = app.util;
|
||
|
||
/**
|
||
* Hydrates static HTML content with data from JSON.
|
||
* Called on page load to hide static placeholders and show dynamic content.
|
||
*/
|
||
function hydrate() {
|
||
// Hide static "Not Validated" warning (will be replaced by dynamic validation)
|
||
const staticWarning = dom.qs('#signature-status-static');
|
||
if (staticWarning) {
|
||
staticWarning.hidden = true;
|
||
}
|
||
|
||
// Digest will be populated by security.renderSignaturesList()
|
||
// which is called after signature verification
|
||
}
|
||
|
||
/**
|
||
* Populates static HTML before saving/publishing.
|
||
* This ensures the page displays content even without JavaScript.
|
||
*/
|
||
async function populateStatic() {
|
||
const data = json.parse();
|
||
const envelope = data.envelope || {};
|
||
const payload = data.payload || {};
|
||
const signatures = Array.isArray(envelope.signatures) ? envelope.signatures : [];
|
||
const files = Array.isArray(payload.files) ? payload.files : [];
|
||
|
||
// Populate all form fields with actual values
|
||
const fields = {
|
||
'#type': payload.type || 'Transmittal',
|
||
'#title': payload.title || '',
|
||
'#owner-name': payload.client || '',
|
||
'#project-name': payload.project || '',
|
||
'#project-number': payload.projectNumber || '',
|
||
'#tracking-number': payload.trackingNumber || '',
|
||
'#date': payload.date || '',
|
||
'#from': payload.from || '',
|
||
'#to': payload.to || '',
|
||
'#purpose': payload.purpose || '',
|
||
'#response-due': payload.responseDue || '',
|
||
'#subject': payload.subject || '',
|
||
'#remarks': payload.remarks || ''
|
||
};
|
||
|
||
Object.keys(fields).forEach(function(selector) {
|
||
const el = dom.qs(selector);
|
||
if (el) {
|
||
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||
el.value = fields[selector];
|
||
el.setAttribute('value', fields[selector]);
|
||
} else {
|
||
el.textContent = fields[selector];
|
||
}
|
||
}
|
||
});
|
||
var typeDisp = dom.qs('#type-display');
|
||
if (typeDisp) { typeDisp.textContent = fields['#type']; }
|
||
|
||
// Render markdown for static display
|
||
if (app.modules.markdown && payload.remarks) {
|
||
const remarksRender = dom.qs('#remarks-render');
|
||
if (remarksRender) {
|
||
remarksRender.innerHTML = app.modules.markdown.render(payload.remarks);
|
||
}
|
||
}
|
||
|
||
// Populate table with file data
|
||
const SELF_HASH = app.constants.SELF_HASH;
|
||
const tbody = dom.qs('.table-wrapper tbody');
|
||
if (tbody) {
|
||
let html = '';
|
||
let rowNum = 0;
|
||
// Separate self-entry from regular files
|
||
var selfFile = null;
|
||
var regularFiles = [];
|
||
files.forEach(function(file) {
|
||
if (file.sha256 === SELF_HASH) {
|
||
selfFile = file;
|
||
} else {
|
||
regularFiles.push(file);
|
||
}
|
||
});
|
||
// If no explicit self-entry in JSON, synthesize one from payload header
|
||
if (!selfFile) {
|
||
selfFile = {
|
||
trackingNumber: payload.trackingNumber || '',
|
||
title: payload.subject || payload.title || '',
|
||
revision: payload.date || '',
|
||
status: payload.purpose || '',
|
||
extension: 'html',
|
||
fileSize: 0,
|
||
sha256: SELF_HASH
|
||
};
|
||
}
|
||
// Build self-link filename from payload fields
|
||
var selfFilename = '';
|
||
var dataModule = app.modules.data;
|
||
if (dataModule && dataModule.buildFileName) {
|
||
selfFilename = dataModule.buildFileName(payload, { extension: 'html' });
|
||
}
|
||
var selfHref = selfFilename ? encodeURI('./' + selfFilename) : '';
|
||
var selfTrackingHtml = selfHref
|
||
? '<a href="' + selfHref + '" class="text-gray-500 hover:underline" target="_blank" rel="noopener">' + util.escapeHtml(selfFile.trackingNumber || '') + '</a>'
|
||
: util.escapeHtml(selfFile.trackingNumber || '');
|
||
// Row 0: self-entry
|
||
html += '<tr class="self-entry">' +
|
||
'<td class="px-2 py-1 text-center text-gray-400">0</td>' +
|
||
'<td class="px-2 py-1 text-gray-500 font-mono">' + selfTrackingHtml + '</td>' +
|
||
'<td class="px-2 py-1 text-gray-500">' + util.escapeHtml(selfFile.title || '') + '</td>' +
|
||
'<td class="px-2 py-1 text-center text-gray-500 font-mono">' + util.escapeHtml(selfFile.revision || '') + '</td>' +
|
||
'<td class="px-2 py-1 text-center text-gray-500">' + util.escapeHtml(selfFile.status || '') + '</td>' +
|
||
'<td class="px-2 py-1 text-center text-gray-500">html</td>' +
|
||
'<td class="px-2 py-1 text-right text-gray-400">\u2014</td>' +
|
||
'<td class="px-2 py-1 font-mono text-[9px] text-gray-400 italic">see above</td>' +
|
||
'</tr>';
|
||
// Remaining files
|
||
regularFiles.forEach(function(file) {
|
||
rowNum++;
|
||
var isUnmatched = !file.sha256 && !file.fileSize;
|
||
var formattedSize = isUnmatched ? '\u2014' : (file.fileSize ? util.formatFileSize(file.fileSize) : '');
|
||
var sizeClass = isUnmatched ? 'px-2 py-1 text-right text-gray-400' : 'px-2 py-1 text-right';
|
||
var hashContent = isUnmatched ? '<span class="italic text-gray-400">pending</span>' : (file.sha256 ? util.formatShortFileHash(file.sha256) : '');
|
||
html += '<tr>' +
|
||
'<td class="px-2 py-1 text-center text-gray-400">' + rowNum + '</td>' +
|
||
'<td class="px-2 py-1">' + (file.trackingNumber || '') + '</td>' +
|
||
'<td class="px-2 py-1">' + (file.title || '') + '</td>' +
|
||
'<td class="px-2 py-1 text-center">' + (file.revision || '') + '</td>' +
|
||
'<td class="px-2 py-1 text-center">' + (file.status || '') + '</td>' +
|
||
'<td class="px-2 py-1 text-center">' + (file.extension || '') + '</td>' +
|
||
'<td class="' + sizeClass + '">' + formattedSize + '</td>' +
|
||
'<td class="px-2 py-1 font-mono text-[9px]">' + hashContent + '</td>' +
|
||
'</tr>';
|
||
});
|
||
tbody.innerHTML = html || '<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>';
|
||
}
|
||
|
||
// Populate digest card (static fallback using verify-card format)
|
||
const digestDisplay = dom.qs('#digest-display');
|
||
if (digestDisplay && envelope.digest) {
|
||
const digestedAt = envelope.digestedAt ? new Date(envelope.digestedAt).toLocaleString() : 'Unknown';
|
||
digestDisplay.innerHTML =
|
||
'<div class="verify-card verify-card--info">' +
|
||
'<div class="verify-card__status verify-card__status--info">Digest (SHA-256)</div>' +
|
||
'<div class="verify-card__detail"><code>' + envelope.digest + '</code></div>' +
|
||
'<div class="verify-card__detail">' + digestedAt + '</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
// Populate digest in Advanced section
|
||
const digestEl = dom.qs('#digest-info');
|
||
if (digestEl && envelope.digest) {
|
||
const digestedAt = envelope.digestedAt ? new Date(envelope.digestedAt).toLocaleString() : 'Unknown';
|
||
digestEl.innerHTML = '<div class="flex flex-col gap-0.5">' +
|
||
'<div><strong>Digest (SHA-256):</strong></div>' +
|
||
'<code class="text-[9px] break-all bg-gray-100 px-1 py-0.5 rounded">' + envelope.digest + '</code>' +
|
||
'<div class="text-gray-500">Created: ' + digestedAt + '</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
// Populate static signature cards
|
||
const sigList = dom.qs('#signatures-list');
|
||
if (sigList && signatures.length > 0) {
|
||
let html = '';
|
||
for (let i = 0; i < signatures.length; i++) {
|
||
const sig = signatures[i];
|
||
const fingerprint = await util.publicKeyFingerprint(sig.publicKeyJwk);
|
||
const fpDisplay = fingerprint === null ? 'unavailable' : (fingerprint || 'Unknown');
|
||
const signedAt = sig.signedAt ? new Date(sig.signedAt).toLocaleString() : 'Unknown';
|
||
html += '<div class="verify-card verify-card--info">' +
|
||
'<div class="verify-card__status verify-card__status--info">Signature ' + (i + 1) + '</div>' +
|
||
'<div class="verify-card__detail">Key: <code>' + fpDisplay + '</code></div>' +
|
||
'<div class="verify-card__detail">' + signedAt + '</div>' +
|
||
'</div>';
|
||
}
|
||
sigList.innerHTML = html;
|
||
}
|
||
|
||
// Show static warning
|
||
const staticWarning = dom.qs('#signature-status-static');
|
||
if (staticWarning) {
|
||
staticWarning.hidden = !(envelope.digest || signatures.length > 0);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Populates form fields from current JSON data at runtime.
|
||
* Used by verification mode to hydrate the form with loaded data.
|
||
*/
|
||
function hydrateForm() {
|
||
const data = json.parse();
|
||
const payload = data.payload || {};
|
||
const files = Array.isArray(payload.files) ? payload.files : [];
|
||
|
||
var fields = {
|
||
'#type': payload.type || 'Transmittal',
|
||
'#title': payload.title || '',
|
||
'#owner-name': payload.client || '',
|
||
'#project-name': payload.project || '',
|
||
'#project-number': payload.projectNumber || '',
|
||
'#tracking-number': payload.trackingNumber || '',
|
||
'#date': payload.date || '',
|
||
'#from': payload.from || '',
|
||
'#to': payload.to || '',
|
||
'#purpose': payload.purpose || '',
|
||
'#response-due': payload.responseDue || '',
|
||
'#subject': payload.subject || '',
|
||
'#remarks': payload.remarks || ''
|
||
};
|
||
|
||
Object.keys(fields).forEach(function (selector) {
|
||
var el = dom.qs(selector);
|
||
if (el) {
|
||
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||
el.value = fields[selector];
|
||
} else {
|
||
el.textContent = fields[selector];
|
||
}
|
||
}
|
||
});
|
||
var typeDisp2 = dom.qs('#type-display');
|
||
if (typeDisp2) { typeDisp2.textContent = fields['#type']; }
|
||
|
||
// Render markdown
|
||
if (app.modules.markdown && payload.remarks) {
|
||
var remarksRender = dom.qs('#remarks-render');
|
||
if (remarksRender) {
|
||
remarksRender.innerHTML = app.modules.markdown.render(payload.remarks);
|
||
}
|
||
}
|
||
|
||
// Render email fields
|
||
if (app.modules.emailTags) {
|
||
if (app.modules.emailTags.render) { app.modules.emailTags.render(); }
|
||
if (app.modules.emailTags.renderFrom) { app.modules.emailTags.renderFrom(); }
|
||
}
|
||
|
||
// Visibility
|
||
if (app.modules.visibility && app.modules.visibility.applyFieldVisibility) {
|
||
app.modules.visibility.applyFieldVisibility();
|
||
}
|
||
}
|
||
|
||
app.modules.hydrate = {
|
||
hydrate: hydrate,
|
||
hydrateForm: hydrateForm,
|
||
populateStatic: populateStatic
|
||
};
|
||
|
||
app.registerInit(function () {
|
||
hydrate();
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const dom = app.dom;
|
||
|
||
// Convert existing state to reactive state
|
||
if (app.createReactiveState) {
|
||
const oldState = app.state;
|
||
app.state = app.createReactiveState({
|
||
mode: oldState.mode || 'edit',
|
||
published: oldState.published || false,
|
||
dirty: oldState.dirty || false
|
||
});
|
||
|
||
// Subscribe to state changes to automatically update UI
|
||
app.state.subscribe(function(property, newValue, oldValue) {
|
||
// Auto-apply state changes
|
||
if (property === 'mode' || property === 'published') {
|
||
state.updateHiddenFields();
|
||
state.apply();
|
||
// Sync preview checkbox to current mode
|
||
if (app.modules.files && app.modules.files.syncPreviewCheckbox) {
|
||
app.modules.files.syncPreviewCheckbox();
|
||
}
|
||
}
|
||
|
||
if (property === 'dirty') {
|
||
// Could update UI indicator here
|
||
}
|
||
});
|
||
}
|
||
|
||
const state = app.state;
|
||
|
||
state.updateHiddenFields = function updateHiddenFields() {
|
||
const modeInput = dom.qs('#mode');
|
||
const publishedInput = dom.qs('#published');
|
||
if (modeInput) {
|
||
modeInput.value = state.mode;
|
||
}
|
||
if (publishedInput) {
|
||
publishedInput.value = state.published ? 'true' : 'false';
|
||
}
|
||
};
|
||
|
||
function toggleEditOnlyElements() {
|
||
const isEdit = state.mode === 'edit';
|
||
dom.qsa('[data-edit-only]').forEach(function (element) {
|
||
const value = element.getAttribute('data-edit-only');
|
||
if (value === 'true') {
|
||
dom.show(element, isEdit);
|
||
} else if (value === 'false') {
|
||
dom.show(element, !isEdit);
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateDirectoryDependentControls() {
|
||
const isEdit = state.mode === 'edit';
|
||
const hasDirectory = !!app.data.selectedDirHandle;
|
||
dom.qsa('[data-requires-directory="true"]').forEach(function (element) {
|
||
const shouldDisable = !(isEdit && hasDirectory);
|
||
if ('disabled' in element) {
|
||
element.disabled = shouldDisable;
|
||
} else {
|
||
if (shouldDisable) {
|
||
element.setAttribute('aria-disabled', 'true');
|
||
} else {
|
||
element.removeAttribute('aria-disabled');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateResponseDueVisibility() {
|
||
var typeDisplay = dom.qs('#type-display');
|
||
var typeHidden = dom.qs('#type');
|
||
var wrapper = dom.qs('#response-due-wrapper');
|
||
if (!wrapper) { return; }
|
||
var typeVal = '';
|
||
if (typeDisplay) {
|
||
typeVal = (typeDisplay.textContent || '').trim();
|
||
} else if (typeHidden) {
|
||
typeVal = (typeHidden.value || '').trim();
|
||
}
|
||
var isSubmittal = typeVal.toLowerCase() === 'submittal';
|
||
if (isSubmittal) {
|
||
wrapper.classList.remove('hidden');
|
||
} else {
|
||
wrapper.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
state.apply = function applyState() {
|
||
updateResponseDueVisibility();
|
||
const inputs = document.querySelectorAll('input, select, textarea');
|
||
inputs.forEach(function (element) {
|
||
const noDisable = element.hasAttribute('data-no-disable') || (!!element.closest('thead') && !!element.closest('.filter-row'));
|
||
element.disabled = (state.mode !== 'edit') && !noDisable;
|
||
});
|
||
|
||
['#owner-name', '#project-name', '#project-number', '#type-display'].forEach(function (selector) {
|
||
const element = dom.qs(selector);
|
||
if (element) {
|
||
element.contentEditable = (state.mode === 'edit') ? 'true' : 'false';
|
||
}
|
||
});
|
||
|
||
const remarksTextarea = dom.qs('#remarks');
|
||
const remarksContainer = dom.qs('#remarks-render-container');
|
||
var mdEditor = app.modules.markdownEditor;
|
||
const isEdit = (state.mode === 'edit') && !state.published;
|
||
|
||
if (remarksTextarea) { remarksTextarea.hidden = true; }
|
||
|
||
if (mdEditor) {
|
||
if (isEdit) {
|
||
// Show rendered preview with click-to-edit; editor loads on first click
|
||
mdEditor.bindRenderClick();
|
||
mdEditor.setRenderClickable(true);
|
||
mdEditor.showRendered();
|
||
} else {
|
||
// View mode: destroy editor, show static rendered HTML
|
||
mdEditor.destroy();
|
||
mdEditor.setRenderClickable(false);
|
||
mdEditor.refreshRender();
|
||
if (remarksContainer) { remarksContainer.hidden = false; }
|
||
}
|
||
} else if (remarksContainer) {
|
||
remarksContainer.hidden = false;
|
||
}
|
||
|
||
const titleInput = dom.qs('#title');
|
||
if (titleInput) {
|
||
const empty = !(titleInput.value || '').trim();
|
||
titleInput.hidden = (state.mode === 'view' && empty);
|
||
}
|
||
|
||
// From field: input in edit mode, rendered mailto link in view mode
|
||
const fromInput = dom.qs('#from');
|
||
const fromRender = dom.qs('#from-render');
|
||
if (fromInput && fromRender) {
|
||
if (isEdit) {
|
||
fromInput.hidden = false;
|
||
fromRender.classList.add('hidden');
|
||
fromRender.hidden = true;
|
||
} else {
|
||
fromInput.hidden = true;
|
||
fromRender.classList.remove('hidden');
|
||
fromRender.hidden = false;
|
||
if (app.modules.emailTags && app.modules.emailTags.renderFrom) {
|
||
app.modules.emailTags.renderFrom();
|
||
}
|
||
}
|
||
}
|
||
|
||
// To field: input in edit mode, rendered mailto links in view mode
|
||
const toInput = dom.qs('#to');
|
||
const toRender = dom.qs('#to-render');
|
||
if (toInput && toRender) {
|
||
if (isEdit) {
|
||
toInput.hidden = false;
|
||
toRender.classList.add('hidden');
|
||
toRender.hidden = true;
|
||
} else {
|
||
toInput.hidden = true;
|
||
toRender.classList.remove('hidden');
|
||
toRender.hidden = false;
|
||
if (app.modules.emailTags && app.modules.emailTags.render) {
|
||
app.modules.emailTags.render();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Logo placeholders: show in edit mode when no logo loaded
|
||
document.querySelectorAll('.logo-cell').forEach(function (cell) {
|
||
var img = cell.querySelector('.logo-img');
|
||
var placeholder = cell.querySelector('.logo-placeholder');
|
||
if (!placeholder) { return; }
|
||
var hasLogo = img && img.getAttribute('src');
|
||
if (hasLogo) {
|
||
cell.classList.add('has-logo');
|
||
} else {
|
||
cell.classList.remove('has-logo');
|
||
}
|
||
if (state.mode === 'edit' && !hasLogo) {
|
||
placeholder.classList.remove('hidden');
|
||
} else {
|
||
placeholder.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
toggleEditOnlyElements();
|
||
updateDirectoryDependentControls();
|
||
};
|
||
|
||
state.detectState = function detectState() {
|
||
var data = app.json.parse();
|
||
var envelope = (data && data.envelope) || {};
|
||
var payload = (data && data.payload) || {};
|
||
var presentation = (data && data.presentation) || {};
|
||
|
||
if (envelope.digest) { return 'published'; }
|
||
|
||
var hasDate = !!(payload.date && payload.date.trim());
|
||
if (hasDate) { return 'draft'; }
|
||
|
||
var hasHeader = !!(payload.client || payload.project || payload.projectNumber ||
|
||
presentation.leftLogo || presentation.rightLogo);
|
||
if (hasHeader) { return 'template'; }
|
||
|
||
return 'clean';
|
||
};
|
||
|
||
app.registerInit(function () {
|
||
state.updateHiddenFields();
|
||
toggleEditOnlyElements();
|
||
updateDirectoryDependentControls();
|
||
var typeInput = dom.qs('#type');
|
||
if (typeInput) {
|
||
typeInput.addEventListener('input', updateResponseDueVisibility);
|
||
}
|
||
updateResponseDueVisibility();
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const dom = app.dom;
|
||
const json = app.json;
|
||
const state = app.state;
|
||
|
||
function computePublishedState() {
|
||
const data = json.parse();
|
||
const digest = (data.envelope && data.envelope.digest) || '';
|
||
state.published = !!digest;
|
||
return state.published;
|
||
}
|
||
|
||
function setMode(mode) {
|
||
if (mode !== 'edit' && mode !== 'view') {
|
||
return;
|
||
}
|
||
if (state.published && mode === 'edit') {
|
||
mode = 'view';
|
||
}
|
||
state.mode = mode;
|
||
}
|
||
|
||
var _previousMode = state.mode;
|
||
|
||
function refresh() {
|
||
const wasPublished = state.published;
|
||
const nowPublished = computePublishedState();
|
||
if (nowPublished && !wasPublished) {
|
||
state.mode = 'view';
|
||
}
|
||
// Re-render file table only when mode actually changes (edit↔view)
|
||
if (state.mode !== _previousMode) {
|
||
_previousMode = state.mode;
|
||
if (app.modules.files && typeof app.modules.files.render === 'function') {
|
||
app.modules.files.render();
|
||
}
|
||
}
|
||
if (app.modules.files && app.modules.files.updateToolbars) {
|
||
app.modules.files.updateToolbars();
|
||
}
|
||
}
|
||
|
||
app.modules.mode = {
|
||
setMode,
|
||
refresh
|
||
};
|
||
|
||
app.registerInit(function () {
|
||
computePublishedState();
|
||
if (state.published) {
|
||
state.mode = 'view';
|
||
} else {
|
||
state.mode = 'edit';
|
||
}
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const dom = app.dom;
|
||
|
||
const visibility = app.modules.visibility = {};
|
||
|
||
// Field visibility rules based on document type
|
||
const FIELD_RULES = {
|
||
'from': ['Transmittal', 'Submittal'],
|
||
'to': ['Transmittal', 'Submittal']
|
||
};
|
||
|
||
function getDocumentType() {
|
||
const typeInput = dom.qs('#type');
|
||
return typeInput ? (typeInput.value || 'Transmittal').trim() : 'Transmittal';
|
||
}
|
||
|
||
function isFieldVisible(fieldId, docType) {
|
||
const rules = FIELD_RULES[fieldId];
|
||
if (!rules) {
|
||
return true; // No rules = always visible
|
||
}
|
||
return rules.includes(docType);
|
||
}
|
||
|
||
function getFieldContainer(fieldId) {
|
||
const input = dom.qs('#' + fieldId);
|
||
if (!input) {
|
||
return null;
|
||
}
|
||
// Find the parent container (the div with col-span-6 or col-span-12)
|
||
let container = input.parentElement;
|
||
while (container && !container.className.includes('col-span-')) {
|
||
container = container.parentElement;
|
||
if (!container || container.tagName === 'FORM') {
|
||
break;
|
||
}
|
||
}
|
||
return container || input.parentElement;
|
||
}
|
||
|
||
visibility.applyFieldVisibility = function applyFieldVisibility() {
|
||
const docType = getDocumentType();
|
||
Object.keys(FIELD_RULES).forEach(function(fieldId) {
|
||
const container = getFieldContainer(fieldId);
|
||
if (!container) { return; }
|
||
container.hidden = !isFieldVisible(fieldId, docType);
|
||
});
|
||
};
|
||
|
||
visibility.bindTypeInput = function bindTypeInput() {
|
||
const typeInput = dom.qs('#type');
|
||
if (!typeInput) {
|
||
return;
|
||
}
|
||
|
||
// Apply visibility on input change
|
||
typeInput.addEventListener('input', function() {
|
||
visibility.applyFieldVisibility();
|
||
if (app.markDirty) {
|
||
app.markDirty();
|
||
}
|
||
});
|
||
|
||
typeInput.addEventListener('change', function() {
|
||
visibility.applyFieldVisibility();
|
||
});
|
||
};
|
||
|
||
app.registerInit(function () {
|
||
visibility.bindTypeInput();
|
||
visibility.applyFieldVisibility();
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const dom = app.dom;
|
||
const util = app.util;
|
||
const json = app.json;
|
||
|
||
let debounceTimer = null;
|
||
const DEBOUNCE_MS = 300; // 300ms debounce
|
||
|
||
async function updateLiveDigest() {
|
||
var digestDisplay = dom.qs('#digest-display');
|
||
if (!digestDisplay) { return; }
|
||
|
||
// Published — let renderSignaturesList handle the display
|
||
var data = json.parse();
|
||
var envelope = (data && data.envelope) || {};
|
||
if (envelope.digest) { return; }
|
||
|
||
// Only compute live digest in edit mode
|
||
if (app.state && app.state.mode !== 'edit') { return; }
|
||
|
||
try {
|
||
// Sync form-field values to JSON only after the app has fully
|
||
// initialised (i.e. loadFromJson has run and app.data.files is
|
||
// populated). Calling syncUiToJson before that would overwrite
|
||
// the saved-draft JSON with the empty in-memory state.
|
||
if (app._initialized && app.modules.files && app.modules.files.syncUiToJson) {
|
||
app.modules.files.syncUiToJson();
|
||
// Re-read after sync so the digest reflects the updated payload.
|
||
data = json.parse();
|
||
}
|
||
var payload = (data && data.payload) || {};
|
||
var payloadStr = util.canonicalStringify(payload);
|
||
var digest = await util.hashString(payloadStr);
|
||
var now = new Date().toLocaleString();
|
||
|
||
// Render draft verify-card
|
||
digestDisplay.innerHTML = '';
|
||
var card = document.createElement('div');
|
||
card.className = 'verify-card verify-card--draft';
|
||
|
||
var status = document.createElement('div');
|
||
status.className = 'verify-card__status verify-card__status--draft';
|
||
status.textContent = 'DRAFT';
|
||
card.appendChild(status);
|
||
|
||
var detail = document.createElement('div');
|
||
detail.className = 'verify-card__detail';
|
||
detail.innerHTML = 'Digest (SHA-256): <code>' + digest + '</code>';
|
||
card.appendChild(detail);
|
||
|
||
var time = document.createElement('div');
|
||
time.className = 'verify-card__detail';
|
||
time.textContent = 'Live \u2014 ' + now;
|
||
card.appendChild(time);
|
||
|
||
digestDisplay.appendChild(card);
|
||
} catch (err) {
|
||
console.error('[live-digest] Failed to calculate digest:', err);
|
||
}
|
||
}
|
||
|
||
function scheduleDigestUpdate() {
|
||
if (debounceTimer) {
|
||
clearTimeout(debounceTimer);
|
||
}
|
||
debounceTimer = setTimeout(updateLiveDigest, DEBOUNCE_MS);
|
||
}
|
||
|
||
function bindFormChanges() {
|
||
const form = dom.qs('#transmittal-form');
|
||
if (!form) {
|
||
return;
|
||
}
|
||
|
||
// Listen to all input changes
|
||
form.addEventListener('input', scheduleDigestUpdate);
|
||
form.addEventListener('change', scheduleDigestUpdate);
|
||
}
|
||
|
||
function subscribeToStateChanges() {
|
||
// Subscribe to reactive state changes
|
||
if (app.state && app.state.subscribe) {
|
||
app.state.subscribe(function(property, newValue, oldValue) {
|
||
scheduleDigestUpdate();
|
||
});
|
||
}
|
||
}
|
||
|
||
app.onDirty(function () {
|
||
scheduleDigestUpdate();
|
||
});
|
||
|
||
app.modules.liveDigest = {
|
||
update: updateLiveDigest,
|
||
schedule: scheduleDigestUpdate
|
||
};
|
||
|
||
app.registerInit(function () {
|
||
bindFormChanges();
|
||
subscribeToStateChanges();
|
||
// Calculate initial digest
|
||
updateLiveDigest();
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const DEBUG = false; // Set to true to enable verbose logging
|
||
|
||
const dom = app.dom;
|
||
const json = app.json;
|
||
const util = app.util;
|
||
|
||
const filesModule = app.modules.files = {};
|
||
|
||
var hasFileSystemAccess = typeof window.showDirectoryPicker === 'function';
|
||
|
||
function requireFileSystemAccess() {
|
||
if (hasFileSystemAccess) { return true; }
|
||
alert('This feature requires the File System Access API.\n\nPlease use a Chromium-based browser on desktop.');
|
||
return false;
|
||
}
|
||
|
||
function hasFiles() {
|
||
return Array.isArray(app.data.files) && app.data.files.length > 0;
|
||
}
|
||
|
||
// Three primary-button states: 'scan', 'verify', 'publish'
|
||
var _primaryIntent = null; // null = auto-detect, or explicit 'scan'|'verify'|'publish'
|
||
var _primaryHandler = null;
|
||
|
||
function setPrimary(intent) {
|
||
_primaryIntent = intent;
|
||
updateToolbars();
|
||
}
|
||
|
||
function setScanningState(active) {
|
||
var wrapper = document.querySelector('.table-wrapper');
|
||
if (!wrapper) { return; }
|
||
if (active) {
|
||
wrapper.classList.add('scanning');
|
||
} else {
|
||
wrapper.classList.remove('scanning');
|
||
}
|
||
}
|
||
|
||
function nowMs() {
|
||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||
return performance.now();
|
||
}
|
||
return Date.now();
|
||
}
|
||
|
||
function formatDuration(ms) {
|
||
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
||
const minutes = Math.floor(totalSeconds / 60);
|
||
const seconds = totalSeconds % 60;
|
||
if (minutes > 0) {
|
||
return minutes + 'm ' + seconds.toString().padStart(2, '0') + 's';
|
||
}
|
||
return seconds + 's';
|
||
}
|
||
|
||
// ── File handle permission and refresh helpers ─────────────────────
|
||
|
||
async function ensureDirHandlePermission(dirHandle) {
|
||
if (!dirHandle || typeof dirHandle.requestPermission !== 'function') {
|
||
return;
|
||
}
|
||
const permissionDescriptor = { mode: 'readwrite' };
|
||
const current = await dirHandle.queryPermission(permissionDescriptor);
|
||
if (current === 'granted') {
|
||
return;
|
||
}
|
||
// Request permission (requires user gesture in most browsers)
|
||
const result = await dirHandle.requestPermission(permissionDescriptor);
|
||
if (result !== 'granted') {
|
||
throw new Error('Read/write permission is required for the selected directory');
|
||
}
|
||
}
|
||
|
||
async function getFreshFile(fileHandle, fallbackData) {
|
||
if (!fileHandle || typeof fileHandle.getFile !== 'function') {
|
||
throw new Error('No valid file handle');
|
||
}
|
||
|
||
// Check permission state before attempting to get file
|
||
try {
|
||
const current = await fileHandle.queryPermission({ mode: 'read' });
|
||
if (current !== 'granted') {
|
||
// Try to request permission (will fail without user gesture)
|
||
try {
|
||
await fileHandle.requestPermission({ mode: 'read' });
|
||
} catch (permErr) {
|
||
// Permission request failed (likely no user gesture), skip with warning
|
||
console.warn('[transmittal] Permission request failed for file:', fileHandle.name || 'unknown', permErr);
|
||
}
|
||
// Check permission again after potential request
|
||
const newPerm = await fileHandle.queryPermission({ mode: 'read' });
|
||
if (newPerm !== 'granted') {
|
||
if (fallbackData) {
|
||
// Use fallback data instead of throwing
|
||
console.log('[transmittal] Permission not granted, using fallback for ' + (fallbackData.path || fallbackData.name || 'unknown file'));
|
||
return fallbackData;
|
||
}
|
||
throw new Error('Permission denied for file access');
|
||
}
|
||
}
|
||
} catch (err) {
|
||
// queryPermission not supported or error
|
||
console.warn('[transmittal] Permission check error:', err);
|
||
}
|
||
|
||
try {
|
||
return await fileHandle.getFile();
|
||
} catch (err) {
|
||
if (err && err.name === 'NotReadableError') {
|
||
if (fallbackData) {
|
||
console.log('[transmittal] NotReadableError, using fallback for ' + (fallbackData.path || fallbackData.name || 'unknown file'));
|
||
return fallbackData;
|
||
}
|
||
throw err;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function updateDirectoryIndicator(name) {
|
||
var indicator = dom.qs('#selected-directory');
|
||
if (indicator) {
|
||
indicator.textContent = name ? name : '';
|
||
}
|
||
updateToolbars();
|
||
}
|
||
|
||
function sortFilesInPlace(list) {
|
||
if (!Array.isArray(list)) {
|
||
return;
|
||
}
|
||
list.sort(util.compareFilesByTrackingRevision);
|
||
}
|
||
|
||
function buildFileHandleMap(files) {
|
||
var map = {};
|
||
(files || []).forEach(function (f) {
|
||
if (f.fileHandle) {
|
||
var key = (f.path || f.name || '').toLowerCase();
|
||
if (key) { map[key] = f.fileHandle; }
|
||
}
|
||
});
|
||
return map;
|
||
}
|
||
|
||
function restoreFileHandles(files, handleMap) {
|
||
(files || []).forEach(function (f) {
|
||
var key = (f.path || f.name || '').toLowerCase();
|
||
if (key && handleMap[key]) {
|
||
f.fileHandle = handleMap[key];
|
||
}
|
||
});
|
||
}
|
||
|
||
function selfEntryDate() {
|
||
var raw = (dom.qs('#date') || {}).value || '';
|
||
var trimmed = raw.trim();
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { return trimmed; }
|
||
var d = new Date(trimmed);
|
||
if (!isNaN(d.getTime())) { return d.toISOString().slice(0, 10); }
|
||
return trimmed;
|
||
}
|
||
|
||
function buildSelfEntry() {
|
||
var tracking = (dom.qs('#tracking-number') || {}).value || '';
|
||
var subject = (dom.qs('#subject') || {}).value || '';
|
||
var title = (dom.qs('#title') || {}).value || '';
|
||
var date = selfEntryDate();
|
||
var purpose = (dom.qs('#purpose') || {}).value || '';
|
||
var d = app.modules.data;
|
||
var filename = (d && d.buildFileName)
|
||
? d.buildFileName({ trackingNumber: tracking, title: title, date: date, purpose: purpose }, { extension: 'html' })
|
||
: '';
|
||
return {
|
||
_isSelf: true,
|
||
path: filename ? ('./' + filename) : '',
|
||
name: filename || '',
|
||
trackingNumber: tracking,
|
||
title: subject || title,
|
||
revision: date,
|
||
status: purpose,
|
||
extension: 'html',
|
||
size: 0,
|
||
fileSize: 0,
|
||
sha256: app.constants.SELF_HASH
|
||
};
|
||
}
|
||
|
||
filesModule.buildSelfEntry = buildSelfEntry;
|
||
|
||
function canonicalFilePayload(entry) {
|
||
const relativePath = entry.path || entry.name || '';
|
||
const filename = relativePath.split('/').pop() || entry.name || '';
|
||
const pathOnly = relativePath.substring(0, relativePath.lastIndexOf('/')) || '';
|
||
return {
|
||
trackingNumber: entry.trackingNumber || '',
|
||
revision: entry.revision || '',
|
||
status: entry.status || '',
|
||
title: entry.title || '',
|
||
path: pathOnly,
|
||
filename: filename,
|
||
extension: entry.extension || '',
|
||
sha256: entry.sha256 || '',
|
||
fileSize: Number(entry.fileSize || entry.size || 0)
|
||
};
|
||
}
|
||
|
||
function updateFilesInJson(files) {
|
||
const data = json.parse();
|
||
const envelope = { ...(data.envelope || {}) };
|
||
const payload = { ...(data.payload || {}) };
|
||
const presentation = { ...(data.presentation || {}) };
|
||
|
||
const sorted = (Array.isArray(files) ? files : []).slice().sort(util.compareFilesByTrackingRevision);
|
||
// Prepend self-entry, then regular files
|
||
var selfEntry = buildSelfEntry();
|
||
payload.files = [canonicalFilePayload(selfEntry)].concat(sorted.map(canonicalFilePayload));
|
||
|
||
// Clear digest when files change (invalidates signatures)
|
||
envelope.digest = '';
|
||
envelope.digestedAt = '';
|
||
envelope.signatures = [];
|
||
|
||
json.setData({ envelope: envelope, payload: payload, presentation: presentation });
|
||
}
|
||
|
||
var ARCHIVE_DIR_NAME = '.archive';
|
||
var MAX_DIRECTORY_DEPTH = 48;
|
||
|
||
function isHiddenName(name) {
|
||
return !!name && name.startsWith('.');
|
||
}
|
||
|
||
function shouldSkipDirectorySegment(name) {
|
||
if (!name) {
|
||
return false;
|
||
}
|
||
if (name === ARCHIVE_DIR_NAME) {
|
||
return true;
|
||
}
|
||
return isHiddenName(name);
|
||
}
|
||
|
||
async function collectFilesRecursive(handle, relPath, out, depth) {
|
||
const currentDepth = depth || 0;
|
||
if (DEBUG) console.log('[transmittal] collectFilesRecursive:', relPath, 'depth:', currentDepth, 'kind:', handle.kind);
|
||
|
||
if (currentDepth > MAX_DIRECTORY_DEPTH) {
|
||
throw new Error('Directory nesting exceeds supported depth');
|
||
}
|
||
if (handle.kind === 'file') {
|
||
if (isHiddenName(handle.name)) {
|
||
if (DEBUG) console.log('[transmittal] Skipping hidden file:', relPath);
|
||
return;
|
||
}
|
||
if (DEBUG) console.log('[transmittal] Adding file to collection:', relPath);
|
||
out.push({ handle, path: relPath, name: handle.name });
|
||
return;
|
||
}
|
||
if (handle.kind !== 'directory') {
|
||
if (DEBUG) console.log('[transmittal] Unknown handle kind:', handle.kind, 'for', relPath);
|
||
return;
|
||
}
|
||
if (shouldSkipDirectorySegment(handle.name) && relPath) {
|
||
if (DEBUG) console.log('[transmittal] Skipping directory segment:', relPath);
|
||
return;
|
||
}
|
||
try {
|
||
if (DEBUG) console.log('[transmittal] Iterating directory:', relPath || 'root');
|
||
let childCount = 0;
|
||
for await (const child of handle.values()) {
|
||
childCount++;
|
||
if (DEBUG) console.log('[transmittal] Found child #' + childCount + ':', child.name, 'kind:', child.kind);
|
||
if (shouldSkipDirectorySegment(child.name)) {
|
||
continue;
|
||
}
|
||
const childPath = relPath ? (relPath + '/' + child.name) : child.name;
|
||
if (DEBUG) console.log('[transmittal] Recursing into:', childPath);
|
||
await collectFilesRecursive(child, childPath, out, currentDepth + 1);
|
||
}
|
||
if (DEBUG) console.log('[transmittal] Finished iterating directory:', relPath || 'root', 'found', childCount, 'children');
|
||
} catch (err) {
|
||
if (err && err.name === 'NotFoundError') {
|
||
console.error('[transmittal] NotFoundError during directory traversal:', relPath || handle.name || '', {
|
||
error: err,
|
||
message: err.message,
|
||
stack: err.stack,
|
||
handleName: handle.name,
|
||
handleKind: handle.kind
|
||
});
|
||
return;
|
||
}
|
||
console.error('[transmittal] Unexpected error during directory traversal:', relPath, err);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function collectDirectoryEntries(rootHandle) {
|
||
const entries = [];
|
||
|
||
if (!rootHandle || rootHandle.kind !== 'directory') {
|
||
throw new Error('Invalid directory handle provided');
|
||
}
|
||
|
||
try {
|
||
// Start directory scan
|
||
if (DEBUG) console.log('[transmittal] Starting directory scan for:', rootHandle.name);
|
||
|
||
for await (const child of rootHandle.values()) {
|
||
if (shouldSkipDirectorySegment(child.name)) {
|
||
continue;
|
||
}
|
||
const childPath = child.name;
|
||
try {
|
||
await collectFilesRecursive(child, childPath, entries, 1);
|
||
} catch (err) {
|
||
if (err && err.name === 'NotFoundError') {
|
||
console.warn('[transmittal] directory entry missing', childPath, err);
|
||
continue;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (err && err.name === 'NotFoundError') {
|
||
console.error('[transmittal] NotFoundError during directory scan', {
|
||
error: err,
|
||
message: err.message,
|
||
stack: err.stack,
|
||
handle: rootHandle,
|
||
handleName: rootHandle.name
|
||
});
|
||
// Return empty entries instead of throwing
|
||
return entries;
|
||
}
|
||
console.error('[transmittal] Unexpected error during directory scan', err);
|
||
throw err;
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
// Promote shared helpers to filesModule for use by sub-modules
|
||
filesModule.nowMs = nowMs;
|
||
filesModule.formatDuration = formatDuration;
|
||
filesModule.updateDirectoryIndicator = updateDirectoryIndicator;
|
||
filesModule.sortFilesInPlace = sortFilesInPlace;
|
||
filesModule.updateFilesInJson = updateFilesInJson;
|
||
filesModule.collectDirectoryEntries = collectDirectoryEntries;
|
||
|
||
async function ensureDirHandle() {
|
||
if (typeof window.showDirectoryPicker !== 'function') {
|
||
throw new Error('File System Access API showDirectoryPicker is required');
|
||
}
|
||
const handle = await window.showDirectoryPicker();
|
||
|
||
// Log the handle details for debugging
|
||
if (DEBUG) {
|
||
console.log('[transmittal] Directory selected:', {
|
||
name: handle.name,
|
||
kind: handle.kind,
|
||
hasQueryPermission: typeof handle.queryPermission === 'function',
|
||
hasRequestPermission: typeof handle.requestPermission === 'function'
|
||
});
|
||
}
|
||
|
||
async function ensureRwPermission(dirHandle) {
|
||
if (!dirHandle) {
|
||
throw new Error('Directory handle is undefined');
|
||
}
|
||
if (typeof dirHandle.requestPermission !== 'function') {
|
||
return;
|
||
}
|
||
const permissionDescriptor = { mode: 'readwrite' };
|
||
if (typeof dirHandle.queryPermission === 'function') {
|
||
const current = await dirHandle.queryPermission(permissionDescriptor);
|
||
if (current === 'granted') {
|
||
return;
|
||
}
|
||
}
|
||
const result = await dirHandle.requestPermission(permissionDescriptor);
|
||
if (result !== 'granted') {
|
||
throw new Error('Read/write permission is required for the selected directory');
|
||
}
|
||
}
|
||
|
||
await ensureRwPermission(handle);
|
||
|
||
// Verify the handle is still valid after permission check
|
||
if (!handle || handle.kind !== 'directory') {
|
||
throw new Error('Directory handle became invalid after permission check');
|
||
}
|
||
|
||
app.data.selectedDirHandle = handle;
|
||
updateDirectoryIndicator(handle.name);
|
||
app.state.apply();
|
||
|
||
if (DEBUG) console.log('[transmittal] Directory handle ready:', handle.name);
|
||
return handle;
|
||
}
|
||
|
||
// Parse folder name: YYYY-MM-DD_TRACKING (STATUS) - TITLE
|
||
// Wraps zddc.parseFolder, preserving this module's null-on-invalid contract.
|
||
function parseFolderName(name) {
|
||
var parsed = zddc.parseFolder(name);
|
||
if (!parsed || !parsed.valid) { return null; }
|
||
return {
|
||
date: parsed.date,
|
||
trackingNumber: parsed.trackingNumber,
|
||
status: parsed.status,
|
||
title: parsed.title
|
||
};
|
||
}
|
||
|
||
function populateFields(parsed) {
|
||
if (!parsed) { return; }
|
||
var map = {
|
||
'date': parsed.date,
|
||
'tracking-number': parsed.trackingNumber,
|
||
'purpose': parsed.status,
|
||
'subject': parsed.title
|
||
};
|
||
Object.keys(map).forEach(function (id) {
|
||
var el = dom.qs('#' + id);
|
||
if (el && map[id]) {
|
||
el.value = map[id];
|
||
}
|
||
});
|
||
}
|
||
|
||
// Shared scan pipeline: collect → populate rows → hash with progress
|
||
// onHash(item, hash) is called per file after hashing; return value sets cell content.
|
||
async function scanEntries(dirHandle, onHash) {
|
||
var entries = await collectDirectoryEntries(dirHandle);
|
||
entries.sort(function (a, b) { return a.path.localeCompare(b.path); });
|
||
|
||
// Phase 0: Ensure directory handle has permission
|
||
await ensureDirHandlePermission(dirHandle);
|
||
|
||
// Phase 1: merge with existing files
|
||
var existingIndex = filesModule.buildExistingIndex(app.data.files || []);
|
||
var existingPasteKeys = Object.keys(existingIndex);
|
||
setScanningState(true);
|
||
var hashCells = [];
|
||
for (var i = 0; i < entries.length; i++) {
|
||
var entry = entries[i];
|
||
try {
|
||
// Check and refresh handle permission before getFile
|
||
var file = await getFreshFile(entry.handle);
|
||
var parsed = (zddc.parseFilename(file.name) || {});
|
||
var fileData = {
|
||
path: entry.path,
|
||
name: file.name,
|
||
size: file.size,
|
||
fileSize: file.size,
|
||
sha256: '',
|
||
trackingNumber: parsed.trackingNumber,
|
||
title: parsed.title,
|
||
revision: parsed.revision,
|
||
status: parsed.status,
|
||
extension: parsed.extension || zddc.splitExtension(file.name).extension,
|
||
fileHandle: entry.handle
|
||
};
|
||
var pasteKey = (fileData.trackingNumber || '').toLowerCase() + '|' + (fileData.revision || '').toLowerCase();
|
||
if (existingPasteKeys.indexOf(pasteKey) === -1) {
|
||
app.data.files.push(fileData);
|
||
var hashCell = filesModule.renderSingleRow(fileData, app.data.files.length - 1);
|
||
hashCells.push({ cell: hashCell, fileData: fileData, handle: entry.handle });
|
||
}
|
||
} catch (err) {
|
||
console.error('[transmittal] Error reading file:', entry.path, err);
|
||
}
|
||
if (i % 20 === 0) {
|
||
await new Promise(function (r) { setTimeout(r, 0); });
|
||
}
|
||
}
|
||
|
||
// Phase 2: hash each file with progress bar
|
||
setScanningState(false);
|
||
for (var j = 0; j < hashCells.length; j++) {
|
||
var item = hashCells[j];
|
||
try {
|
||
var fill = item.cell ? item.cell.querySelector('.hash-progress-fill') : null;
|
||
var onProgress = fill ? function (f) {
|
||
return function (loaded, total) {
|
||
var pct = total > 0 ? Math.round((loaded / total) * 100) : 0;
|
||
f.style.width = pct + '%';
|
||
};
|
||
}(fill) : null;
|
||
var file = await getFreshFile(item.handle);
|
||
var hash = await util.hashFile(file, onProgress);
|
||
item.fileData.sha256 = hash;
|
||
if (onHash) {
|
||
onHash(item, hash);
|
||
} else if (item.cell) {
|
||
item.cell.textContent = util.formatShortFileHash(hash);
|
||
}
|
||
} catch (err) {
|
||
console.error('[transmittal] Error hashing file:', item.fileData.path, err);
|
||
if (item.cell) { item.cell.textContent = 'error'; }
|
||
}
|
||
}
|
||
return hashCells;
|
||
}
|
||
|
||
function finalizeAfterScan() {
|
||
var handleMap = buildFileHandleMap(app.data.files);
|
||
updateFilesInJson(app.data.files);
|
||
filesModule.render();
|
||
filesModule.loadFromJson({ filesOnly: true });
|
||
restoreFileHandles(app.data.files, handleMap);
|
||
app.state.apply();
|
||
}
|
||
|
||
async function selectDirectory(event) {
|
||
if (DEBUG) console.log('[transmittal] ========== SELECT DIRECTORY STARTED ==========');
|
||
const trigger = event && event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||
if (trigger) {
|
||
trigger.disabled = true;
|
||
trigger.classList.add('opacity-60');
|
||
}
|
||
try {
|
||
var dirHandle = app.data.selectedDirHandle;
|
||
if (!dirHandle) {
|
||
dirHandle = await ensureDirHandle();
|
||
}
|
||
populateFields(parseFolderName(dirHandle.name));
|
||
await scanEntries(dirHandle);
|
||
finalizeAfterScan();
|
||
app.markDirty();
|
||
setPrimary('publish');
|
||
} catch (err) {
|
||
if (err && (err.name === 'AbortError' || err.name === 'NotAllowedError')) {
|
||
if (DEBUG) console.log('[transmittal] User cancelled directory selection');
|
||
} else {
|
||
console.error('[transmittal] selectDirectory failed', err);
|
||
}
|
||
} finally {
|
||
setScanningState(false);
|
||
if (trigger) {
|
||
trigger.disabled = false;
|
||
trigger.classList.remove('opacity-60');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function writeFileToSelectedDir(filename, contents, mime) {
|
||
if (!app.data.selectedDirHandle) {
|
||
throw new Error('No directory selected');
|
||
}
|
||
const fileHandle = await app.data.selectedDirHandle.getFileHandle(filename, { create: true });
|
||
const writable = await fileHandle.createWritable();
|
||
await writable.write(new Blob([contents], { type: mime || 'text/plain' }));
|
||
await writable.close();
|
||
}
|
||
|
||
filesModule.writeFileToSelectedDir = writeFileToSelectedDir;
|
||
|
||
// ── Paste helpers ─────────────────────────────────────
|
||
// Expected formats (tab-separated, 3-5 adjacent columns):
|
||
// 3 cols: Tracking \t Title \t Revision+Status ("A (IFR)")
|
||
// 4 cols: Tracking \t Title \t Revision \t Status
|
||
// 5 cols: Tracking \t Title \t Revision \t Status \t Extension
|
||
var MAX_PASTE_COLS = 5;
|
||
var HEADER_RE = /^(#|tracking|number|title|revision|status|ext)/i;
|
||
|
||
function isHeaderLine(cols) {
|
||
if (!cols || !cols.length) { return false; }
|
||
return HEADER_RE.test((cols[0] || '').trim());
|
||
}
|
||
|
||
function columnsToFileRow(cols) {
|
||
var tracking = (cols[0] || '').trim();
|
||
if (!tracking) { return null; }
|
||
var revision = (cols[2] || '').trim();
|
||
var status = (cols[3] || '').trim();
|
||
var extension = (cols[4] || '').trim().toLowerCase();
|
||
|
||
// 3-column paste: split "A (IFR)" into revision "A" and status "IFR"
|
||
if (cols.length <= 3 && revision) {
|
||
var spaceIdx = revision.indexOf(' ');
|
||
if (spaceIdx > 0) {
|
||
status = revision.substring(spaceIdx + 1).trim().replace(/^\(+/, '').replace(/\)+$/, '');
|
||
revision = revision.substring(0, spaceIdx).trim();
|
||
}
|
||
}
|
||
return {
|
||
trackingNumber: tracking,
|
||
title: (cols[1] || '').trim(),
|
||
revision: revision,
|
||
status: status,
|
||
extension: extension,
|
||
path: '', name: '', size: 0, fileSize: 0, sha256: ''
|
||
};
|
||
}
|
||
|
||
// Parse plain-text tab-separated clipboard data.
|
||
// Returns { rows: [...], tooWide: false } or { rows: [], tooWide: true }.
|
||
function parseClipboardText(text) {
|
||
var lines = (text || '').split(/\r?\n/);
|
||
var rows = [];
|
||
for (var i = 0; i < lines.length; i++) {
|
||
var line = lines[i].trim();
|
||
if (!line) { continue; }
|
||
var cols = line.split('\t');
|
||
if (isHeaderLine(cols)) { continue; }
|
||
if (cols.length > MAX_PASTE_COLS) {
|
||
return { rows: [], tooWide: true, colCount: cols.length };
|
||
}
|
||
var row = columnsToFileRow(cols);
|
||
if (row) { rows.push(row); }
|
||
}
|
||
return { rows: rows, tooWide: false };
|
||
}
|
||
|
||
function pasteFileKey(trackingNumber, revision) {
|
||
return (trackingNumber || '').toLowerCase() + '|' + (revision || '').toLowerCase();
|
||
}
|
||
|
||
function buildExistingIndex(files) {
|
||
var index = {};
|
||
for (var i = 0; i < files.length; i++) {
|
||
index[pasteFileKey(files[i].trackingNumber, files[i].revision)] = i;
|
||
}
|
||
return index;
|
||
}
|
||
filesModule.buildExistingIndex = buildExistingIndex;
|
||
|
||
async function handlePasteFiles(mode, clipText) {
|
||
var setStatus = app.modules.data && app.modules.data.setStatus;
|
||
try {
|
||
var text = clipText || await navigator.clipboard.readText();
|
||
var result = parseClipboardText(text);
|
||
if (result.tooWide) {
|
||
if (setStatus) {
|
||
setStatus('Paste has ' + result.colCount + ' columns (max ' + MAX_PASTE_COLS + '). ' +
|
||
'Copy only: Tracking, Title, Revision, [Status], [Extension]', 'error');
|
||
}
|
||
return;
|
||
}
|
||
var rows = result.rows;
|
||
if (!rows.length) {
|
||
if (setStatus) { setStatus('No valid rows found. Expected tab-separated: Tracking, Title, Revision, [Status], [Ext]', 'error'); }
|
||
return;
|
||
}
|
||
|
||
var added = 0, updated = 0;
|
||
if (mode === 'new') {
|
||
app.data.files = [];
|
||
for (var i = 0; i < rows.length; i++) {
|
||
app.data.files.push(rows[i]);
|
||
added++;
|
||
}
|
||
} else {
|
||
var index = buildExistingIndex(app.data.files || []);
|
||
for (var j = 0; j < rows.length; j++) {
|
||
var row = rows[j];
|
||
var key = pasteFileKey(row.trackingNumber, row.revision);
|
||
if (index.hasOwnProperty(key)) {
|
||
var existing = app.data.files[index[key]];
|
||
existing.title = row.title;
|
||
existing.status = row.status;
|
||
if (row.extension) { existing.extension = row.extension; }
|
||
updated++;
|
||
} else {
|
||
app.data.files.push(row);
|
||
added++;
|
||
}
|
||
}
|
||
}
|
||
|
||
filesModule.sortFilesInPlace(app.data.files);
|
||
updateFilesInJson(app.data.files);
|
||
filesModule.render();
|
||
app.state.apply();
|
||
app.markDirty();
|
||
setPrimary('verify');
|
||
|
||
var msg = mode === 'new'
|
||
? 'Replaced file list: ' + added + ' rows'
|
||
: added + ' added, ' + updated + ' updated';
|
||
if (setStatus) { setStatus(msg, 'success'); }
|
||
} catch (err) {
|
||
console.error('[transmittal] paste failed', err);
|
||
if (setStatus) { setStatus('Paste failed: ' + (err && err.message ? err.message : err), 'error'); }
|
||
}
|
||
}
|
||
|
||
// ── Toolbar visibility per state ────────────────────
|
||
function updateToolbars() {
|
||
var docState = app.state.detectState();
|
||
buildBottomBar(docState);
|
||
}
|
||
|
||
function createMenuItem(label, handler, options) {
|
||
var btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'dropdown-item' + ((options && options.danger) ? ' text-red-600' : '');
|
||
btn.setAttribute('role', 'menuitem');
|
||
btn.textContent = label;
|
||
btn.addEventListener('click', handler);
|
||
return btn;
|
||
}
|
||
|
||
function createSeparator(label) {
|
||
var el = document.createElement('div');
|
||
el.className = 'dropdown-separator';
|
||
if (label) { el.textContent = label; }
|
||
return el;
|
||
}
|
||
|
||
function applyPrimaryButton(btn, intent) {
|
||
if (_primaryHandler) { btn.removeEventListener('click', _primaryHandler); }
|
||
btn.disabled = false;
|
||
btn.classList.remove('opacity-60');
|
||
if (intent === 'publish') {
|
||
btn.textContent = 'Publish';
|
||
_primaryHandler = function () {
|
||
document.dispatchEvent(new CustomEvent('transmittal:open-publish'));
|
||
};
|
||
} else if (intent === 'verify') {
|
||
btn.textContent = 'Verify Directory';
|
||
_primaryHandler = function () {
|
||
if (!requireFileSystemAccess()) { return; }
|
||
verifyFiles();
|
||
};
|
||
} else {
|
||
btn.textContent = 'Scan Directory';
|
||
_primaryHandler = function () {
|
||
if (!requireFileSystemAccess()) { return; }
|
||
selectDirectory({ currentTarget: null });
|
||
};
|
||
}
|
||
btn.addEventListener('click', _primaryHandler);
|
||
}
|
||
|
||
function buildBottomBar(docState) {
|
||
var primaryBtn = dom.qs('#bottom-primary');
|
||
var dropdown = dom.qs('#bottom-dropdown');
|
||
if (!primaryBtn || !dropdown) { return; }
|
||
|
||
var isPublished = docState === 'published';
|
||
var d = app.modules.data || {};
|
||
|
||
dropdown.innerHTML = '';
|
||
dropdown.classList.add('hidden');
|
||
|
||
if (isPublished) {
|
||
applyPrimaryButton(primaryBtn, 'verify');
|
||
|
||
dropdown.appendChild(createMenuItem('Copy Table', function () {
|
||
if (d.handleCopyTable) { d.handleCopyTable(); }
|
||
}));
|
||
dropdown.appendChild(createMenuItem('Copy JSON', function () {
|
||
if (d.handleCopyJson) { d.handleCopyJson(); }
|
||
}));
|
||
dropdown.appendChild(createSeparator());
|
||
dropdown.appendChild(createMenuItem('Add Signature', function () {
|
||
if (app.modules.security && app.modules.security.addSignature) {
|
||
app.modules.security.addSignature();
|
||
}
|
||
}));
|
||
dropdown.appendChild(createMenuItem('Acknowledge Receipt', function () {
|
||
if (app.modules.security && app.modules.security.addSignature) {
|
||
app.modules.security.addSignature({ label: 'Received By' });
|
||
}
|
||
}));
|
||
dropdown.appendChild(createSeparator());
|
||
dropdown.appendChild(createMenuItem('Revise', function () {
|
||
if (d.handleRevise) { d.handleRevise(); }
|
||
}));
|
||
dropdown.appendChild(createMenuItem('Import HTML', function () {
|
||
if (d.handleImportHtml) { d.handleImportHtml(); }
|
||
}));
|
||
dropdown.appendChild(createMenuItem('Reset', function () {
|
||
if (app.modules.reset && app.modules.reset.handleReset) {
|
||
app.modules.reset.handleReset();
|
||
}
|
||
}, { danger: true }));
|
||
dropdown.appendChild(createSeparator());
|
||
dropdown.appendChild(createMenuItem('Create Index', function () {
|
||
if (!requireFileSystemAccess()) { return; }
|
||
if (filesModule.generateArchiveRedirects) {
|
||
filesModule.generateArchiveRedirects().catch(function (err) {
|
||
console.error('[transmittal] create-index failed', err);
|
||
});
|
||
}
|
||
}));
|
||
} else {
|
||
// Edit: respect explicit intent, or auto-detect
|
||
var intent = _primaryIntent;
|
||
if (!intent) {
|
||
intent = hasFiles() ? 'verify' : 'scan';
|
||
}
|
||
applyPrimaryButton(primaryBtn, intent);
|
||
|
||
dropdown.appendChild(createMenuItem('Scan Directory', function () {
|
||
if (!requireFileSystemAccess()) { return; }
|
||
selectDirectory({ currentTarget: null });
|
||
}));
|
||
dropdown.appendChild(createMenuItem('Verify Directory', function () {
|
||
if (!requireFileSystemAccess()) { return; }
|
||
verifyFiles();
|
||
}));
|
||
dropdown.appendChild(createSeparator());
|
||
dropdown.appendChild(createMenuItem('Publish', function () {
|
||
document.dispatchEvent(new CustomEvent('transmittal:open-publish'));
|
||
}));
|
||
dropdown.appendChild(createMenuItem('Save Draft', function () {
|
||
if (d.handleSaveHtmlDraft) { d.handleSaveHtmlDraft(); }
|
||
}));
|
||
dropdown.appendChild(createMenuItem('Create Folder', async function () {
|
||
var setStatus = d.setStatus;
|
||
try {
|
||
// Sync UI so payload reflects current form values
|
||
filesModule.syncUiToJson();
|
||
var data = json.parse();
|
||
var payload = (data && data.payload) || {};
|
||
if (!payload.trackingNumber) {
|
||
if (setStatus) { setStatus('Enter a tracking number first', 'error'); }
|
||
return;
|
||
}
|
||
var folderName = d.buildFolderName(payload);
|
||
// Sanitize for filesystem
|
||
folderName = folderName.replace(/[<>:"/\\|?*]+/g, '_').replace(/_+/g, '_');
|
||
|
||
// Prompt for staging directory
|
||
var stagingHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||
var newFolderHandle = await stagingHandle.getDirectoryHandle(folderName, { create: true });
|
||
|
||
// Set as selected directory for subsequent file operations
|
||
app.data.selectedDirHandle = newFolderHandle;
|
||
app.data.selectedDirName = folderName;
|
||
if (filesModule.updateDirectoryIndicator) {
|
||
filesModule.updateDirectoryIndicator();
|
||
}
|
||
|
||
// Save a draft into the new folder
|
||
var pub = app.modules.publish;
|
||
if (pub && typeof pub.syncUiToJson === 'function' && typeof pub.buildHtmlString === 'function') {
|
||
await pub.syncUiToJson({ sign: false, computeDigest: false });
|
||
var html = await pub.buildHtmlString();
|
||
var draftName = d.buildFileName(
|
||
((json.parse() || {}).payload || {}),
|
||
{ extension: 'html', draft: true }
|
||
);
|
||
await writeFileToSelectedDir(draftName, html, 'text/html');
|
||
// Verify both the folder and draft file exist
|
||
var warnings = [];
|
||
try {
|
||
await stagingHandle.getDirectoryHandle(folderName);
|
||
} catch (_) {
|
||
warnings.push('folder \"' + folderName + '\" could not be verified');
|
||
}
|
||
try {
|
||
await newFolderHandle.getFileHandle(draftName);
|
||
} catch (_) {
|
||
warnings.push('draft file \"' + draftName + '\" could not be verified');
|
||
}
|
||
if (warnings.length) {
|
||
if (setStatus) { setStatus('Warning: ' + warnings.join('; ') + '. Path may be too long for Windows.', 'error'); }
|
||
} else if (setStatus) {
|
||
setStatus('Draft saved to ' + folderName + '. Close this file and open ' + draftName + ' from the new folder.', 'success');
|
||
}
|
||
} else {
|
||
if (setStatus) { setStatus('Created folder: ' + folderName, 'success'); }
|
||
}
|
||
} catch (err) {
|
||
if (err && err.name === 'AbortError') { return; }
|
||
console.error('[transmittal] create-folder failed', err);
|
||
if (setStatus) { setStatus('Create folder failed: ' + (err.message || err), 'error'); }
|
||
}
|
||
}));
|
||
dropdown.appendChild(createSeparator());
|
||
dropdown.appendChild(createMenuItem('Paste New Rows', async function () {
|
||
var text;
|
||
try { text = await navigator.clipboard.readText(); } catch (e) {
|
||
if (d.setStatus) { d.setStatus('Clipboard access denied', 'error'); }
|
||
return;
|
||
}
|
||
var result = parseClipboardText(text);
|
||
if (result.tooWide) {
|
||
if (d.setStatus) {
|
||
d.setStatus('Paste has ' + result.colCount + ' columns (max ' + MAX_PASTE_COLS + '). ' +
|
||
'Copy only: Tracking, Title, Revision, [Status], [Extension]', 'error');
|
||
}
|
||
return;
|
||
}
|
||
if (!result.rows.length) {
|
||
if (d.setStatus) { d.setStatus('No valid rows on clipboard', 'error'); }
|
||
return;
|
||
}
|
||
if (confirm('Replace file list with ' + result.rows.length + ' rows from clipboard?')) {
|
||
handlePasteFiles('new', text);
|
||
}
|
||
}));
|
||
dropdown.appendChild(createMenuItem('Paste Append Rows', function () {
|
||
handlePasteFiles('append');
|
||
}));
|
||
dropdown.appendChild(createMenuItem('Copy Table', function () {
|
||
if (d.handleCopyTable) { d.handleCopyTable(); }
|
||
}));
|
||
dropdown.appendChild(createMenuItem('Remove Files', function () {
|
||
if (!app.data.files.length) {
|
||
if (d.setStatus) { d.setStatus('File list is already empty', 'error'); }
|
||
return;
|
||
}
|
||
if (confirm('Remove all ' + app.data.files.length + ' files from the list? Header info and remarks will be kept.')) {
|
||
app.data.files = [];
|
||
updateFilesInJson([]);
|
||
filesModule.render();
|
||
app.state.apply();
|
||
app.markDirty();
|
||
if (d.setStatus) { d.setStatus('File list cleared', 'success'); }
|
||
}
|
||
}));
|
||
dropdown.appendChild(createSeparator());
|
||
dropdown.appendChild(createMenuItem('Import HTML', function () {
|
||
if (d.handleImportHtml) { d.handleImportHtml(); }
|
||
}));
|
||
dropdown.appendChild(createMenuItem('Copy JSON', function () {
|
||
if (d.handleCopyJson) { d.handleCopyJson(); }
|
||
}));
|
||
dropdown.appendChild(createMenuItem('Paste JSON', function () {
|
||
if (d.handleLoadFromClipboard) { d.handleLoadFromClipboard(); }
|
||
}));
|
||
dropdown.appendChild(createSeparator());
|
||
dropdown.appendChild(createMenuItem('Reset', function () {
|
||
if (app.modules.reset && app.modules.reset.handleReset) {
|
||
app.modules.reset.handleReset();
|
||
}
|
||
}, { danger: true }));
|
||
dropdown.appendChild(createSeparator());
|
||
dropdown.appendChild(createMenuItem('Create Index', function () {
|
||
if (!requireFileSystemAccess()) { return; }
|
||
if (filesModule.generateArchiveRedirects) {
|
||
filesModule.generateArchiveRedirects().catch(function (err) {
|
||
console.error('[transmittal] create-index failed', err);
|
||
});
|
||
}
|
||
}));
|
||
}
|
||
}
|
||
|
||
// ── Bottom bar dropdown toggle ──────────────────────
|
||
function initBottomBarToggle() {
|
||
var toggle = dom.qs('#bottom-toggle');
|
||
var menu = dom.qs('#bottom-dropdown');
|
||
if (!toggle || !menu) { return; }
|
||
|
||
toggle.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
var open = !menu.classList.contains('hidden');
|
||
menu.classList.toggle('hidden', open);
|
||
toggle.setAttribute('aria-expanded', String(!open));
|
||
});
|
||
|
||
menu.addEventListener('click', function () {
|
||
menu.classList.add('hidden');
|
||
toggle.setAttribute('aria-expanded', 'false');
|
||
});
|
||
|
||
document.addEventListener('click', function (e) {
|
||
var container = dom.qs('#bottom-menu');
|
||
if (container && !container.contains(e.target)) {
|
||
menu.classList.add('hidden');
|
||
toggle.setAttribute('aria-expanded', 'false');
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
async function refreshDirectory() {
|
||
var dirHandle = app.data.selectedDirHandle;
|
||
if (!dirHandle) { return; }
|
||
try {
|
||
// Ensure directory handle has permission before scanning
|
||
await ensureDirHandlePermission(dirHandle);
|
||
|
||
// Build expected-hash lookup from current JSON
|
||
var expectedHashes = {};
|
||
var currentData = json.parse();
|
||
var loadedFiles = (currentData && currentData.payload && Array.isArray(currentData.payload.files)) ? currentData.payload.files : [];
|
||
loadedFiles.forEach(function (f) {
|
||
var key = (f.path ? f.path + '/' : '') + f.filename;
|
||
if (f.sha256) { expectedHashes[key.toLowerCase()] = f.sha256.toLowerCase(); }
|
||
});
|
||
var hasExpected = Object.keys(expectedHashes).length > 0;
|
||
var matchCount = 0;
|
||
var mismatchCount = 0;
|
||
|
||
var hashCells = await scanEntries(dirHandle, hasExpected ? function (item, hash) {
|
||
if (!item.cell) { return; }
|
||
var display = util.formatShortFileHash(hash);
|
||
var fileKey = (item.fileData.path || item.fileData.name).toLowerCase();
|
||
var expected = expectedHashes[fileKey];
|
||
if (expected && expected === hash.toLowerCase()) {
|
||
item.cell.innerHTML = '<span class="hash-match" title="Hash matches expected value">\u2713</span> ' + display;
|
||
matchCount++;
|
||
} else if (expected) {
|
||
item.cell.innerHTML = '<span class="hash-mismatch" title="Hash does NOT match expected value">\u2717</span> ' + display;
|
||
mismatchCount++;
|
||
} else {
|
||
item.cell.textContent = display;
|
||
}
|
||
} : null);
|
||
|
||
if (hasExpected && app.modules.data && app.modules.data.setStatus) {
|
||
if (mismatchCount === 0 && matchCount > 0) {
|
||
app.modules.data.setStatus(matchCount + '/' + hashCells.length + ' files verified', 'success');
|
||
} else if (mismatchCount > 0) {
|
||
app.modules.data.setStatus(mismatchCount + ' hash mismatch(es) \u2014 ' + matchCount + ' matched', 'error');
|
||
}
|
||
}
|
||
|
||
finalizeAfterScan();
|
||
if (!hasExpected) { app.markDirty(); }
|
||
} catch (err) {
|
||
console.error('[transmittal] Refresh failed', err);
|
||
} finally {
|
||
setScanningState(false);
|
||
}
|
||
}
|
||
|
||
// ── Verify row helpers ────────────────────────────────────
|
||
function findRowByFileIndex(idx) {
|
||
var cell = document.querySelector('td[data-index="' + idx + '"]');
|
||
return cell ? cell.closest('tr') : null;
|
||
}
|
||
|
||
function getHashCell(row) {
|
||
return row ? row.querySelector('td:last-child') : null;
|
||
}
|
||
|
||
function setRowVerifyState(row, state) {
|
||
if (!row) { return; }
|
||
row.classList.remove('verify-match', 'verify-mismatch', 'verify-missing', 'verify-new', 'verify-progress');
|
||
if (state) { row.classList.add('verify-' + state); }
|
||
}
|
||
|
||
function clearAllVerifyStates() {
|
||
var rows = document.querySelectorAll('tr.verify-match, tr.verify-mismatch, tr.verify-missing, tr.verify-new, tr.verify-progress');
|
||
rows.forEach(function (r) {
|
||
r.classList.remove('verify-match', 'verify-mismatch', 'verify-missing', 'verify-new', 'verify-progress');
|
||
});
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
var div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function showPathDiff(trackingCell, expectedPath, actualPath) {
|
||
var diffEl = document.createElement('div');
|
||
diffEl.className = 'path-diff';
|
||
var del = document.createElement('del');
|
||
del.textContent = expectedPath;
|
||
var ins = document.createElement('ins');
|
||
ins.textContent = actualPath;
|
||
diffEl.appendChild(del);
|
||
diffEl.appendChild(document.createTextNode(' \u2192 '));
|
||
diffEl.appendChild(ins);
|
||
trackingCell.appendChild(diffEl);
|
||
}
|
||
|
||
// Hash every directory entry upfront, before any event-loop yields.
|
||
// On file:// origins in Chromium, FileSystemFileHandle.getFile() fails after
|
||
// macrotask boundaries have elapsed since the handle was created. The fix is
|
||
// to call getFile() + hashFile() back-to-back for each entry in one tight
|
||
// sequential pass, producing a complete index before any UI yields occur.
|
||
// Returns { sizeIndex, nameIndex } where each entry has hash already computed.
|
||
async function buildVerifyIndex(entries, onProgress) {
|
||
var sizeIndex = {}; // fileSize → [candidate]
|
||
var nameIndex = {}; // "tracking\trevision" → candidate
|
||
var entryList = []; // List of file metadata for later use
|
||
for (var i = 0; i < entries.length; i++) {
|
||
var entry = entries[i];
|
||
try {
|
||
var file = await entry.handle.getFile();
|
||
var hash = await util.hashFile(file);
|
||
var cand = {
|
||
handle: entry.handle,
|
||
path: entry.path,
|
||
name: file.name,
|
||
fileSize: file.size,
|
||
hash: hash,
|
||
matched: false
|
||
};
|
||
// Store file metadata for later use
|
||
entryList.push({
|
||
handle: entry.handle,
|
||
path: entry.path,
|
||
name: file.name,
|
||
fileSize: file.size,
|
||
hash: hash,
|
||
parsed: (zddc.parseFilename(entry.name) || {})
|
||
});
|
||
// size index
|
||
if (!sizeIndex[file.size]) { sizeIndex[file.size] = []; }
|
||
sizeIndex[file.size].push(cand);
|
||
// name index
|
||
var parsed = (zddc.parseFilename(entry.name) || {});
|
||
if (parsed.trackingNumber) {
|
||
var nkey = parsed.trackingNumber.toLowerCase() + '\t' + (parsed.revision || '').toLowerCase();
|
||
if (!nameIndex[nkey]) { nameIndex[nkey] = []; }
|
||
nameIndex[nkey].push(cand);
|
||
}
|
||
if (onProgress) { onProgress(i + 1, entries.length); }
|
||
} catch (err) {
|
||
console.warn('[transmittal] verify skip entry', entry.path, err);
|
||
}
|
||
}
|
||
return { sizeIndex: sizeIndex, nameIndex: nameIndex, entryList: entryList };
|
||
}
|
||
|
||
// Find a matching directory entry by sha256 hash.
|
||
function findByHash(sizeIndex, fileSize, expectedHash) {
|
||
if (!expectedHash || fileSize == null) { return null; }
|
||
var candidates = sizeIndex[fileSize];
|
||
if (!candidates) { return null; }
|
||
var target = expectedHash.toLowerCase();
|
||
for (var c = 0; c < candidates.length; c++) {
|
||
var cand = candidates[c];
|
||
if (cand.matched) { continue; }
|
||
if (cand.hash && cand.hash.toLowerCase() === target) {
|
||
cand.matched = true;
|
||
return cand;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Find a matching directory entry by tracking number + revision.
|
||
function findByTrackingRevision(nameIndex, trackingNumber, revision) {
|
||
var key = (trackingNumber || '').toLowerCase() + '\t' + (revision || '').toLowerCase();
|
||
var candidates = nameIndex[key];
|
||
if (!candidates || !candidates.length) { return null; }
|
||
for (var i = 0; i < candidates.length; i++) {
|
||
if (!candidates[i].matched) {
|
||
candidates[i].matched = true;
|
||
return candidates[i];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Count unmatched entries in a size index
|
||
function countUnmatched(sizeIndex) {
|
||
var count = 0;
|
||
for (var size in sizeIndex) {
|
||
var group = sizeIndex[size];
|
||
for (var i = 0; i < group.length; i++) {
|
||
if (!group[i].matched) { count++; }
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
|
||
async function verifyFiles() {
|
||
var isPublished = !!app.state.published;
|
||
var setStatus = app.modules.data && app.modules.data.setStatus;
|
||
try {
|
||
var dirHandle = app.data.selectedDirHandle;
|
||
if (!dirHandle) {
|
||
dirHandle = await ensureDirHandle();
|
||
}
|
||
clearAllVerifyStates();
|
||
if (setStatus) { setStatus('Hashing directory\u2026', 'info'); }
|
||
|
||
var entries = await collectDirectoryEntries(dirHandle);
|
||
var allFiles = app.data.files || [];
|
||
|
||
// Hash every file upfront before any UI yields — on file:// origins,
|
||
// Chromium invalidates FileSystemFileHandle after macrotask boundaries.
|
||
var idx = await buildVerifyIndex(entries);
|
||
var sizeIndex = idx.sizeIndex;
|
||
var nameIndex = idx.nameIndex;
|
||
var entryList = idx.entryList || [];
|
||
|
||
// Build pasteKey index for existing files to avoid duplicates
|
||
var existingIndex = buildExistingIndex(allFiles);
|
||
var existingPasteKeys = Object.keys(existingIndex);
|
||
|
||
if (isPublished) {
|
||
// ── Published: read-only verification by hash ──
|
||
var verified = 0;
|
||
var missingCount = 0;
|
||
|
||
for (var p = 0; p < allFiles.length; p++) {
|
||
var tf = allFiles[p];
|
||
var row = findRowByFileIndex(p);
|
||
var hCell = getHashCell(row);
|
||
var trackingCell = row ? row.querySelector('td[data-field="trackingNumber"]') : null;
|
||
|
||
setRowVerifyState(row, 'progress');
|
||
await new Promise(function (r) { setTimeout(r, 0); });
|
||
|
||
var fileSize = tf.fileSize != null ? tf.fileSize : tf.size;
|
||
var found = findByHash(sizeIndex, fileSize, tf.sha256);
|
||
|
||
if (found) {
|
||
verified++;
|
||
tf.fileHandle = found.handle;
|
||
setRowVerifyState(row, 'match');
|
||
if (hCell) {
|
||
hCell.innerHTML = '<span style="color:#166534;font-weight:700;">\u2713</span> ' + escapeHtml(util.formatShortFileHash(tf.sha256));
|
||
}
|
||
var expectedPath = tf.path || tf.name || '';
|
||
if (trackingCell && expectedPath && found.path && expectedPath !== found.path) {
|
||
showPathDiff(trackingCell, expectedPath, found.path);
|
||
}
|
||
} else {
|
||
missingCount++;
|
||
setRowVerifyState(row, 'missing');
|
||
if (hCell) {
|
||
hCell.innerHTML = '<span style="color:#92400e;">\u26A0 not found</span>';
|
||
}
|
||
}
|
||
}
|
||
|
||
var extra = countUnmatched(sizeIndex);
|
||
var msg = verified + ' verified';
|
||
if (missingCount) { msg += ', ' + missingCount + ' missing'; }
|
||
if (extra) { msg += ', ' + extra + ' extra in directory'; }
|
||
if (setStatus) {
|
||
setStatus(msg, missingCount ? 'error' : 'success');
|
||
}
|
||
} else {
|
||
// ── Edit: match rows against directory ──
|
||
// Rows WITH hash+size → match by size+hash
|
||
// Rows WITHOUT hash → match by tracking+revision, then populate hash/size
|
||
var verified = 0;
|
||
var populated = 0;
|
||
var notFound = 0;
|
||
var dirty = false;
|
||
|
||
for (var mi = 0; mi < allFiles.length; mi++) {
|
||
var mf = allFiles[mi];
|
||
var mRow = findRowByFileIndex(mi);
|
||
var mhCell = getHashCell(mRow);
|
||
|
||
setRowVerifyState(mRow, 'progress');
|
||
await new Promise(function (r) { setTimeout(r, 0); });
|
||
|
||
var hasHash = !!mf.sha256;
|
||
var mFileSize = mf.fileSize != null ? mf.fileSize : mf.size;
|
||
|
||
if (hasHash && mFileSize != null) {
|
||
var foundByHash = findByHash(sizeIndex, mFileSize, mf.sha256);
|
||
if (foundByHash) {
|
||
mf.fileHandle = foundByHash.handle;
|
||
verified++;
|
||
setRowVerifyState(mRow, 'match');
|
||
if (mhCell) {
|
||
mhCell.innerHTML = '<span style="color:#166534;font-weight:700;">\u2713</span> ' + escapeHtml(util.formatShortFileHash(mf.sha256));
|
||
}
|
||
} else {
|
||
mf.fileHandle = null;
|
||
notFound++;
|
||
setRowVerifyState(mRow, 'missing');
|
||
if (mhCell) {
|
||
mhCell.innerHTML = '<span style="color:#92400e;">\u26A0 not found</span>';
|
||
}
|
||
}
|
||
} else {
|
||
var foundByName = findByTrackingRevision(nameIndex, mf.trackingNumber, mf.revision);
|
||
if (foundByName) {
|
||
mf.fileHandle = foundByName.handle;
|
||
mf.path = foundByName.path;
|
||
mf.name = foundByName.name;
|
||
mf.size = foundByName.fileSize;
|
||
mf.fileSize = foundByName.fileSize;
|
||
mf.sha256 = foundByName.hash;
|
||
mf.extension = mf.extension || zddc.splitExtension(foundByName.name).extension;
|
||
populated++;
|
||
dirty = true;
|
||
setRowVerifyState(mRow, 'match');
|
||
if (mhCell) {
|
||
mhCell.innerHTML = '<span style="color:#166534;font-weight:700;">\u2713</span> ' + escapeHtml(util.formatShortFileHash(mf.sha256));
|
||
}
|
||
} else {
|
||
notFound++;
|
||
setRowVerifyState(mRow, 'missing');
|
||
if (mhCell) {
|
||
mhCell.innerHTML = '<span style="color:#92400e;">\u26A0 not found</span>';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (dirty) {
|
||
updateFilesInJson(allFiles);
|
||
filesModule.render();
|
||
app.state.apply();
|
||
app.markDirty();
|
||
}
|
||
|
||
var editMsg = '';
|
||
var parts = [];
|
||
if (verified) { parts.push(verified + ' verified'); }
|
||
if (populated) { parts.push(populated + ' matched'); }
|
||
if (notFound) { parts.push(notFound + ' not found'); }
|
||
editMsg = parts.join(', ') || 'No files to verify';
|
||
|
||
// Add new files from directory that don't match existing pasteKeys
|
||
var added = 0;
|
||
for (var ei = 0; ei < entryList.length; ei++) {
|
||
var entryData = entryList[ei];
|
||
if (!entryData.parsed.trackingNumber && !entryData.parsed.revision) continue; // Skip files without ZDDC pattern
|
||
var pasteKey = (entryData.parsed.trackingNumber || '').toLowerCase() + '|' + (entryData.parsed.revision || '').toLowerCase();
|
||
if (existingPasteKeys.indexOf(pasteKey) === -1) {
|
||
// New file - use pre-hashed file data
|
||
var fileData = {
|
||
path: entryData.path,
|
||
name: entryData.name,
|
||
size: entryData.fileSize,
|
||
fileSize: entryData.fileSize,
|
||
sha256: '',
|
||
trackingNumber: entryData.parsed.trackingNumber,
|
||
title: entryData.parsed.title,
|
||
revision: entryData.parsed.revision,
|
||
status: entryData.parsed.status,
|
||
extension: entryData.parsed.extension || zddc.splitExtension(entryData.name).extension,
|
||
fileHandle: entryData.handle
|
||
};
|
||
app.data.files.push(fileData);
|
||
added++;
|
||
}
|
||
}
|
||
if (added > 0) {
|
||
dirty = true;
|
||
updateFilesInJson(app.data.files);
|
||
filesModule.render();
|
||
app.state.apply();
|
||
app.markDirty();
|
||
editMsg = (editMsg ? editMsg + ', ' : '') + added + ' new file(s) added';
|
||
}
|
||
|
||
if (setStatus) {
|
||
setStatus(editMsg, notFound && !added ? 'error' : 'success');
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (err && (err.name === 'AbortError' || err.name === 'NotAllowedError')) {
|
||
if (DEBUG) console.log('[transmittal] User cancelled verify');
|
||
} else {
|
||
console.error('[transmittal] verifyFiles failed', err);
|
||
if (setStatus) { setStatus('Verify failed: ' + (err.message || err), 'error'); }
|
||
}
|
||
}
|
||
}
|
||
|
||
document.addEventListener('transmittal:scan-directory', function () {
|
||
selectDirectory({ currentTarget: dom.qs('#bottom-primary') });
|
||
});
|
||
|
||
document.addEventListener('transmittal:verify-directory', function () {
|
||
verifyFiles();
|
||
});
|
||
|
||
filesModule.bindActionButtons = function bindActionButtons() {
|
||
// Reveal the menu now that JS is running
|
||
var bottomMenu = dom.qs('#bottom-menu');
|
||
if (bottomMenu) { bottomMenu.hidden = false; }
|
||
var noJsNotice = dom.qs('#no-js-notice');
|
||
if (noJsNotice) { dom.show(noJsNotice, false); }
|
||
initBottomBarToggle();
|
||
updateToolbars();
|
||
};
|
||
|
||
filesModule.updateToolbars = updateToolbars;
|
||
|
||
filesModule.syncUiToJson = function syncUiToJson() {
|
||
const val = function (selector) {
|
||
const el = dom.qs(selector);
|
||
return el ? (el.value || '') : '';
|
||
};
|
||
|
||
function toIsoDate(value) {
|
||
if (!value) {
|
||
return '';
|
||
}
|
||
const trimmed = String(value).trim();
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||
return trimmed;
|
||
}
|
||
const match = trimmed.match(/^(?:[A-Za-z]+\s+)?([A-Za-z]{3,})\s+(\d{1,2}),\s*(\d{4})$/);
|
||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||
if (match) {
|
||
const monthIndex = months.indexOf(match[1].slice(0, 3));
|
||
if (monthIndex >= 0) {
|
||
const day = String(parseInt(match[2], 10)).padStart(2, '0');
|
||
const month = String(monthIndex + 1).padStart(2, '0');
|
||
const year = match[3];
|
||
return year + '-' + month + '-' + day;
|
||
}
|
||
}
|
||
const date = new Date(trimmed);
|
||
if (!Number.isNaN(date.getTime())) {
|
||
return date.toISOString().slice(0, 10);
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
const data = json.parse();
|
||
const envelope = { ...(data.envelope || {}) };
|
||
const presentation = { ...(data.presentation || {}) };
|
||
|
||
const payload = {
|
||
version: 1,
|
||
type: val('#type'),
|
||
title: val('#title') || '',
|
||
client: (dom.qs('#owner-name')?.textContent) || '',
|
||
project: (dom.qs('#project-name')?.textContent) || '',
|
||
projectNumber: (dom.qs('#project-number')?.textContent) || '',
|
||
date: toIsoDate(val('#date')),
|
||
trackingNumber: val('#tracking-number'),
|
||
from: val('#from'),
|
||
to: val('#to'),
|
||
purpose: val('#purpose'),
|
||
responseDue: val('#response-due'),
|
||
subject: val('#subject'),
|
||
remarks: val('#remarks'),
|
||
files: (function () {
|
||
var memFiles = Array.isArray(app.data.files) ? app.data.files : [];
|
||
var sorted = memFiles.slice().sort(util.compareFilesByTrackingRevision).map(canonicalFilePayload);
|
||
var self = buildSelfEntry();
|
||
return [canonicalFilePayload(self)].concat(sorted);
|
||
})()
|
||
};
|
||
|
||
const leftLogoEl = dom.qs('#left-logo');
|
||
const rightLogoEl = dom.qs('#right-logo');
|
||
presentation.leftLogo = leftLogoEl && leftLogoEl.src ? leftLogoEl.src : '';
|
||
presentation.rightLogo = rightLogoEl && rightLogoEl.src ? rightLogoEl.src : '';
|
||
|
||
json.setData({ envelope: envelope, payload: payload, presentation: presentation });
|
||
};
|
||
|
||
filesModule.loadFromJson = function loadFromJson(options) {
|
||
const opts = options || {};
|
||
const data = json.parse();
|
||
const payload = (data && data.payload) || {};
|
||
if (!opts.filesOnly) {
|
||
const assignValue = function (selector, value) {
|
||
const element = dom.qs(selector);
|
||
if (element) {
|
||
element.value = value || '';
|
||
}
|
||
};
|
||
const assignText = function (selector, value) {
|
||
const element = dom.qs(selector);
|
||
if (element) {
|
||
element.textContent = value || '';
|
||
}
|
||
};
|
||
assignValue('#type', payload.type || 'Transmittal');
|
||
var typeDisplay = dom.qs('#type-display');
|
||
if (typeDisplay) { typeDisplay.textContent = payload.type || 'Transmittal'; }
|
||
assignValue('#title', payload.title || '');
|
||
assignText('#owner-name', payload.client || '');
|
||
assignText('#project-name', payload.project || '');
|
||
assignText('#project-number', payload.projectNumber || '');
|
||
assignValue('#date', payload.date || '');
|
||
assignValue('#tracking-number', payload.trackingNumber || '');
|
||
assignValue('#from', payload.from || '');
|
||
assignValue('#to', payload.to || '');
|
||
assignValue('#purpose', payload.purpose || '');
|
||
assignValue('#response-due', payload.responseDue || '');
|
||
assignValue('#subject', payload.subject || '');
|
||
const remarks = dom.qs('#remarks');
|
||
if (remarks) {
|
||
remarks.value = payload.remarks || '';
|
||
}
|
||
|
||
// Load logos from presentation data
|
||
const presentation = (data && data.presentation) || {};
|
||
const leftLogoEl = dom.qs('#left-logo');
|
||
const rightLogoEl = dom.qs('#right-logo');
|
||
if (leftLogoEl && presentation.leftLogo) {
|
||
leftLogoEl.src = presentation.leftLogo;
|
||
}
|
||
if (rightLogoEl && presentation.rightLogo) {
|
||
rightLogoEl.src = presentation.rightLogo;
|
||
}
|
||
}
|
||
var SELF_HASH = app.constants.SELF_HASH;
|
||
const files = Array.isArray(payload.files)
|
||
? payload.files.filter(function (f) { return f.sha256 !== SELF_HASH; })
|
||
: [];
|
||
app.data.files = files.map(function (entry) {
|
||
const pathOnly = entry.path || '';
|
||
const filename = entry.filename || '';
|
||
const relativePath = pathOnly ? (pathOnly + '/' + filename) : filename;
|
||
|
||
// When filename is empty (e.g., from pasted files), reconstruct from trackingNumber
|
||
let baseName = filename || relativePath.split('/').pop() || '';
|
||
if (!baseName && entry.trackingNumber) {
|
||
baseName = zddc.joinExtension(entry.trackingNumber, entry.extension || '');
|
||
}
|
||
|
||
const fileSize = entry.fileSize || entry.size || 0;
|
||
return {
|
||
path: relativePath,
|
||
name: baseName,
|
||
size: fileSize,
|
||
fileSize: fileSize,
|
||
sha256: entry.sha256 || '',
|
||
trackingNumber: entry.trackingNumber || '',
|
||
title: entry.title || '',
|
||
revision: entry.revision || '',
|
||
status: entry.status || '',
|
||
extension: zddc.splitExtension(baseName).extension
|
||
};
|
||
});
|
||
sortFilesInPlace(app.data.files);
|
||
// Don't call updateFilesInJson here - it clears digest/signatures
|
||
// The files are already in the JSON, we're just loading them into app.data.files
|
||
updateDirectoryIndicator(app.data.selectedDirHandle ? app.data.selectedDirHandle.name : '');
|
||
|
||
// Render the table to populate it with file data
|
||
filesModule.render();
|
||
|
||
if (!opts.filesOnly && app.modules.markdown && typeof app.modules.markdown.refresh === 'function') {
|
||
app.modules.markdown.refresh();
|
||
}
|
||
// Apply field visibility after loading data to ensure UI stays in sync
|
||
if (!opts.filesOnly && app.modules.visibility && typeof app.modules.visibility.applyFieldVisibility === 'function') {
|
||
app.modules.visibility.applyFieldVisibility();
|
||
}
|
||
};
|
||
|
||
app.registerInit(function () {
|
||
updateDirectoryIndicator(app.data.selectedDirHandle ? app.data.selectedDirHandle.name : '');
|
||
filesModule.bindActionButtons();
|
||
filesModule.setupTableEditing();
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
var dom = app.dom;
|
||
var util = app.util;
|
||
var filesModule = app.modules.files;
|
||
|
||
var ARCHIVE_DIR_NAME = '.archive';
|
||
|
||
function sanitizeUrlSegment(value, fallback) {
|
||
var str = (value || '').toString().trim();
|
||
if (!str) {
|
||
return fallback;
|
||
}
|
||
var cleaned = str
|
||
.replace(/[^A-Za-z0-9._~-]+/g, '-')
|
||
.replace(/-+/g, '-')
|
||
.replace(/^-+|-+$/g, '');
|
||
return cleaned || fallback;
|
||
}
|
||
|
||
function encodeRelativePath(path) {
|
||
return path.split('/').map(function (segment) {
|
||
return encodeURIComponent(segment);
|
||
}).join('/');
|
||
}
|
||
|
||
function buildRedirectHtml(targetHref, displayName) {
|
||
var safeHrefAttr = util.escapeHtmlAttribute(targetHref);
|
||
var safeLabel = util.escapeHtml(displayName || targetHref);
|
||
return '<!DOCTYPE html>\n' +
|
||
'<html lang="en">\n' +
|
||
'<head>\n' +
|
||
'<meta charset="utf-8">\n' +
|
||
'<meta http-equiv="refresh" content="0; url=' + safeHrefAttr + '">\n' +
|
||
'<title>' + safeLabel + '</title>\n' +
|
||
'</head>\n' +
|
||
'<body>\n' +
|
||
'<p>Redirecting to <a href="' + safeHrefAttr + '">' + safeLabel + '</a>.</p>\n' +
|
||
'</body>\n' +
|
||
'</html>\n';
|
||
}
|
||
|
||
async function ensureArchiveDirectory(rootHandle) {
|
||
return rootHandle.getDirectoryHandle(ARCHIVE_DIR_NAME, { create: true });
|
||
}
|
||
|
||
async function writeRedirectFile(directoryHandle, filename, html) {
|
||
var fileHandle = await directoryHandle.getFileHandle(filename, { create: true });
|
||
var writable = await fileHandle.createWritable();
|
||
await writable.write(new Blob([html], { type: 'text/html' }));
|
||
await writable.close();
|
||
}
|
||
|
||
function groupFilesByTracking(files) {
|
||
var map = new Map();
|
||
files.forEach(function (file) {
|
||
var key = file.trackingNumber || '';
|
||
if (!map.has(key)) {
|
||
map.set(key, []);
|
||
}
|
||
map.get(key).push(file);
|
||
});
|
||
return map;
|
||
}
|
||
|
||
function determineLatestByTracking(grouped) {
|
||
var latest = new Map();
|
||
grouped.forEach(function (files, tracking) {
|
||
if (!files || !files.length) {
|
||
return;
|
||
}
|
||
var sorted = files.slice().sort(function (a, b) {
|
||
var revisionCompare = util.compareRevisionPriority(a.revision, b.revision);
|
||
if (revisionCompare !== 0) {
|
||
return revisionCompare;
|
||
}
|
||
var pathCompare = (a.path || '').localeCompare(b.path || '');
|
||
if (pathCompare !== 0) {
|
||
return pathCompare;
|
||
}
|
||
return (a.name || '').localeCompare(b.name || '');
|
||
});
|
||
latest.set(tracking, sorted[sorted.length - 1]);
|
||
});
|
||
return latest;
|
||
}
|
||
|
||
function buildRedirectFilenames(file) {
|
||
var trackingSegment = sanitizeUrlSegment(file.trackingNumber || '', 'tracking');
|
||
var rawRevision = (file.revision || '').trim();
|
||
var revisionSegment = rawRevision ? sanitizeUrlSegment(rawRevision, 'rev') : '';
|
||
var rawHash = (file.sha256 || '').trim();
|
||
var hashSegment = rawHash ? sanitizeUrlSegment(rawHash, 'hash') : '';
|
||
var htmlSuffix = '.html';
|
||
|
||
var revisionFilename = revisionSegment ? (trackingSegment + '_' + revisionSegment + htmlSuffix) : '';
|
||
var hashFilename = hashSegment ? (hashSegment + htmlSuffix) : '';
|
||
var latestFilename = trackingSegment + htmlSuffix;
|
||
|
||
return {
|
||
latestFilename: latestFilename,
|
||
revisionFilename: revisionFilename,
|
||
hashFilename: hashFilename
|
||
};
|
||
}
|
||
|
||
async function resolveFileMetadata(entry) {
|
||
var parsed = zddc.parseFilename(entry.name || entry.path || '') || {};
|
||
if (!parsed.trackingNumber) {
|
||
return null;
|
||
}
|
||
var file = await entry.handle.getFile();
|
||
var sha256 = await util.hashFile(file);
|
||
return {
|
||
path: entry.path,
|
||
name: file.name,
|
||
extension: parsed.extension || zddc.splitExtension(file.name).extension,
|
||
trackingNumber: parsed.trackingNumber,
|
||
revision: parsed.revision,
|
||
status: parsed.status,
|
||
title: parsed.title,
|
||
sha256: sha256,
|
||
size: file.size
|
||
};
|
||
}
|
||
|
||
function buildRelativeHref(relativePath) {
|
||
var encoded = encodeRelativePath(relativePath);
|
||
return '../' + encoded;
|
||
}
|
||
|
||
async function createRedirectFiles(archiveHandle, file, isLatest) {
|
||
var filenames = buildRedirectFilenames(file);
|
||
var href = buildRelativeHref(file.path);
|
||
var html = buildRedirectHtml(href, file.name);
|
||
|
||
var writes = [];
|
||
if (filenames.revisionFilename) {
|
||
writes.push(writeRedirectFile(archiveHandle, filenames.revisionFilename, html));
|
||
}
|
||
if (filenames.hashFilename) {
|
||
writes.push(writeRedirectFile(archiveHandle, filenames.hashFilename, html));
|
||
}
|
||
if (isLatest) {
|
||
writes.push(writeRedirectFile(archiveHandle, filenames.latestFilename, html));
|
||
}
|
||
|
||
await Promise.all(writes);
|
||
return true;
|
||
}
|
||
|
||
async function pickIndexDirectory() {
|
||
if (typeof window.showDirectoryPicker !== 'function') {
|
||
throw new Error('File System Access API showDirectoryPicker is required');
|
||
}
|
||
return window.showDirectoryPicker();
|
||
}
|
||
|
||
filesModule.generateArchiveRedirects = async function generateArchiveRedirects() {
|
||
var rootHandle;
|
||
try {
|
||
rootHandle = await pickIndexDirectory();
|
||
} catch (err) {
|
||
if (err && (err.name === 'AbortError' || err.name === 'NotAllowedError')) {
|
||
console.log('[transmittal] Create Index cancelled.');
|
||
return;
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
var overallStart = filesModule.nowMs();
|
||
var entries = await filesModule.collectDirectoryEntries(rootHandle);
|
||
if (!entries.length) {
|
||
console.log('[transmittal] Create Index: No files found.');
|
||
return;
|
||
}
|
||
|
||
var archiveHandle = await ensureArchiveDirectory(rootHandle);
|
||
var files = [];
|
||
for (var i = 0; i < entries.length; i++) {
|
||
try {
|
||
var metadata = await resolveFileMetadata(entries[i]);
|
||
if (metadata) {
|
||
files.push(metadata);
|
||
}
|
||
} catch (err) {
|
||
console.warn('[transmittal] create-index skip', entries[i].path, err);
|
||
}
|
||
}
|
||
if (!files.length) {
|
||
console.log('[transmittal] Create Index: No ZDDC files found.');
|
||
return;
|
||
}
|
||
|
||
var grouped = groupFilesByTracking(files);
|
||
var latest = determineLatestByTracking(grouped);
|
||
|
||
var writePromises = files.map(function (file) {
|
||
var isLatest = latest.get(file.trackingNumber) === file;
|
||
return createRedirectFiles(archiveHandle, file, isLatest)
|
||
.catch(function (err) {
|
||
console.error('[transmittal] create-index write failed', file.path, err);
|
||
return null;
|
||
});
|
||
});
|
||
|
||
var results = await Promise.all(writePromises);
|
||
var created = results.filter(function (result) { return result !== null; }).length;
|
||
|
||
var totalElapsed = filesModule.nowMs() - overallStart;
|
||
console.log('[transmittal] Create Index: Generated redirects for ' + created + ' files in ' + filesModule.formatDuration(totalElapsed) + '.');
|
||
};
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
var dom = app.dom;
|
||
var util = app.util;
|
||
var filesModule = app.modules.files;
|
||
|
||
// Build ZDDC filename from file row data — delegates to shared zddc.formatFilename()
|
||
function buildZddcFileName(fileData, droppedExt) {
|
||
var ext = (fileData.extension || droppedExt || '').toLowerCase().replace(/^\.+/, '');
|
||
return zddc.formatFilename({
|
||
trackingNumber: fileData.trackingNumber || '',
|
||
revision: fileData.revision || '',
|
||
status: fileData.status || '',
|
||
title: fileData.title || '',
|
||
extension: ext,
|
||
});
|
||
}
|
||
|
||
function setupRowDropTargets() {
|
||
if (app.state.mode !== 'edit') { return; }
|
||
var rows = document.querySelectorAll('table tbody tr:not(.self-entry)');
|
||
rows.forEach(function (row) {
|
||
row.addEventListener('dragover', function (e) {
|
||
if (app.state.mode !== 'edit') { return; }
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
row.classList.add('ring-2', 'ring-blue-400', 'bg-blue-50');
|
||
});
|
||
row.addEventListener('dragleave', function () {
|
||
row.classList.remove('ring-2', 'ring-blue-400', 'bg-blue-50');
|
||
});
|
||
row.addEventListener('drop', async function (e) {
|
||
row.classList.remove('ring-2', 'ring-blue-400', 'bg-blue-50');
|
||
if (app.state.mode !== 'edit') { return; }
|
||
var droppedFile = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||
if (!droppedFile) { return; }
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
var setStatus = app.modules.data && app.modules.data.setStatus;
|
||
|
||
// Prompt for directory if none selected
|
||
if (!app.data.selectedDirHandle) {
|
||
try {
|
||
app.data.selectedDirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||
app.data.selectedDirName = app.data.selectedDirHandle.name || '';
|
||
if (filesModule.updateDirectoryIndicator) {
|
||
filesModule.updateDirectoryIndicator();
|
||
}
|
||
} catch (pickErr) {
|
||
if (setStatus) { setStatus('A directory is required to copy files', 'error'); }
|
||
return;
|
||
}
|
||
}
|
||
|
||
var indexCell = row.querySelector('td[data-index]');
|
||
if (!indexCell) { return; }
|
||
var fileIndex = Number(indexCell.dataset.index);
|
||
var fileData = app.data.files[fileIndex];
|
||
if (!fileData) { return; }
|
||
|
||
var droppedExt = zddc.splitExtension(droppedFile.name).extension;
|
||
var zddcName = buildZddcFileName(fileData, droppedExt);
|
||
if (!zddcName) { zddcName = droppedFile.name; }
|
||
|
||
var hashCell = row.querySelector('td:last-child');
|
||
try {
|
||
if (hashCell) { hashCell.textContent = 'copying\u2026'; }
|
||
var dirHandle = app.data.selectedDirHandle;
|
||
var newHandle = await dirHandle.getFileHandle(zddcName, { create: true });
|
||
var writable = await newHandle.createWritable();
|
||
await writable.write(droppedFile);
|
||
await writable.close();
|
||
|
||
if (hashCell) { hashCell.textContent = 'hashing\u2026'; }
|
||
var written = await newHandle.getFile();
|
||
var hash = await util.hashFile(written);
|
||
|
||
fileData.fileHandle = newHandle;
|
||
fileData.path = zddcName;
|
||
fileData.name = zddcName;
|
||
fileData.size = written.size;
|
||
fileData.fileSize = written.size;
|
||
fileData.sha256 = hash;
|
||
if (!fileData.extension) { fileData.extension = droppedExt; }
|
||
|
||
filesModule.updateFilesInJson(app.data.files);
|
||
filesModule.render();
|
||
app.state.apply();
|
||
app.markDirty();
|
||
if (setStatus) { setStatus('Copied \u2192 ' + zddcName, 'success'); }
|
||
} catch (err) {
|
||
console.error('[transmittal] row file drop failed', err);
|
||
if (hashCell) { hashCell.textContent = 'error'; }
|
||
if (setStatus) { setStatus('Drop failed: ' + (err.message || err), 'error'); }
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
filesModule.clearTable = function clearTable() {
|
||
var tbody = document.querySelector('table tbody');
|
||
if (tbody) { tbody.innerHTML = ''; }
|
||
};
|
||
|
||
filesModule.renderSingleRow = function renderSingleRow(file, index) {
|
||
var tbody = document.querySelector('table tbody');
|
||
if (!tbody) { return null; }
|
||
var row = document.createElement('tr');
|
||
|
||
var numCell = document.createElement('td');
|
||
numCell.className = 'px-2 py-1 align-top text-center text-gray-400';
|
||
numCell.textContent = String(index + 1);
|
||
row.appendChild(numCell);
|
||
|
||
var trackingCell = document.createElement('td');
|
||
trackingCell.className = 'px-2 py-1 align-top whitespace-nowrap font-mono';
|
||
var link = document.createElement('a');
|
||
link.textContent = file.trackingNumber || '';
|
||
var relativePath = file.path || file.name || '';
|
||
if (relativePath) {
|
||
link.href = encodeURI(relativePath);
|
||
link.dataset.relativePath = relativePath;
|
||
} else {
|
||
link.href = '#';
|
||
}
|
||
link.title = relativePath || (file.name || '');
|
||
link.className = 'text-blue-600 hover:underline';
|
||
var ext = (file.extension || zddc.splitExtension(relativePath).extension);
|
||
if (app.constants.viewableExts.indexOf(ext) !== -1) {
|
||
link.target = '_blank';
|
||
link.rel = 'noopener';
|
||
} else {
|
||
var filename = (relativePath.split('/').pop() || file.name || 'download');
|
||
link.setAttribute('download', filename);
|
||
}
|
||
trackingCell.appendChild(link);
|
||
trackingCell.contentEditable = 'false';
|
||
trackingCell.dataset.index = String(index);
|
||
trackingCell.dataset.field = 'trackingNumber';
|
||
row.appendChild(trackingCell);
|
||
|
||
var titleCell = document.createElement('td');
|
||
titleCell.className = 'px-2 py-1 align-top whitespace-normal break-words w-full';
|
||
titleCell.textContent = file.title || '';
|
||
titleCell.contentEditable = (app.state.mode === 'edit').toString();
|
||
titleCell.setAttribute('role', 'textbox');
|
||
titleCell.setAttribute('aria-multiline', 'false');
|
||
titleCell.setAttribute('tabindex', '0');
|
||
titleCell.dataset.index = String(index);
|
||
titleCell.dataset.field = 'title';
|
||
row.appendChild(titleCell);
|
||
|
||
var revisionCell = document.createElement('td');
|
||
revisionCell.className = 'px-2 py-1 align-top whitespace-nowrap text-center font-mono';
|
||
revisionCell.textContent = file.revision || '';
|
||
revisionCell.contentEditable = (app.state.mode === 'edit').toString();
|
||
revisionCell.setAttribute('role', 'textbox');
|
||
revisionCell.setAttribute('aria-multiline', 'false');
|
||
revisionCell.setAttribute('tabindex', '0');
|
||
revisionCell.dataset.index = String(index);
|
||
revisionCell.dataset.field = 'revision';
|
||
row.appendChild(revisionCell);
|
||
|
||
var statusCell = document.createElement('td');
|
||
statusCell.className = 'px-2 py-1 align-top whitespace-nowrap text-center font-mono';
|
||
statusCell.textContent = file.status || '';
|
||
statusCell.contentEditable = (app.state.mode === 'edit').toString();
|
||
statusCell.setAttribute('role', 'textbox');
|
||
statusCell.setAttribute('aria-multiline', 'false');
|
||
statusCell.setAttribute('tabindex', '0');
|
||
statusCell.dataset.index = String(index);
|
||
statusCell.dataset.field = 'status';
|
||
row.appendChild(statusCell);
|
||
|
||
var extCell = document.createElement('td');
|
||
extCell.className = 'px-2 py-1 align-top whitespace-nowrap text-center font-mono';
|
||
extCell.textContent = (file.extension || '').toLowerCase();
|
||
extCell.contentEditable = 'false';
|
||
row.appendChild(extCell);
|
||
|
||
var sizeCell = document.createElement('td');
|
||
sizeCell.className = 'px-2 py-1 align-top whitespace-nowrap text-right font-mono';
|
||
var fileSizeValue = (file.fileSize != null ? file.fileSize : file.size);
|
||
sizeCell.textContent = util.formatFileSize(fileSizeValue);
|
||
sizeCell.contentEditable = 'false';
|
||
row.appendChild(sizeCell);
|
||
|
||
var hashCell = document.createElement('td');
|
||
hashCell.className = 'px-2 py-1 align-top font-mono text-[9px] whitespace-normal break-all leading-snug';
|
||
if (file.sha256) {
|
||
hashCell.textContent = util.formatShortFileHash(file.sha256);
|
||
} else {
|
||
var prog = document.createElement('div');
|
||
prog.className = 'hash-progress';
|
||
var bar = document.createElement('div');
|
||
bar.className = 'hash-progress-bar';
|
||
var fill = document.createElement('div');
|
||
fill.className = 'hash-progress-fill';
|
||
bar.appendChild(fill);
|
||
prog.appendChild(bar);
|
||
hashCell.appendChild(prog);
|
||
}
|
||
row.appendChild(hashCell);
|
||
|
||
tbody.appendChild(row);
|
||
return hashCell;
|
||
};
|
||
|
||
function renderSelfRow(tbody, rowNum) {
|
||
var self = filesModule.buildSelfEntry ? filesModule.buildSelfEntry() : null;
|
||
if (!self) { return; }
|
||
var row = document.createElement('tr');
|
||
row.className = 'self-entry';
|
||
|
||
var numCell = document.createElement('td');
|
||
numCell.className = 'px-2 py-1 align-top text-center text-gray-400';
|
||
numCell.textContent = String(rowNum);
|
||
row.appendChild(numCell);
|
||
|
||
var trackingCell = document.createElement('td');
|
||
trackingCell.className = 'px-2 py-1 align-top whitespace-nowrap font-mono text-gray-500';
|
||
var selfPath = self.path || self.name || '';
|
||
if (selfPath) {
|
||
var link = document.createElement('a');
|
||
link.href = encodeURI(selfPath);
|
||
link.textContent = self.trackingNumber || '';
|
||
link.className = 'text-gray-500 hover:underline';
|
||
link.target = '_blank';
|
||
link.rel = 'noopener';
|
||
trackingCell.appendChild(link);
|
||
} else {
|
||
trackingCell.textContent = self.trackingNumber || '';
|
||
}
|
||
row.appendChild(trackingCell);
|
||
|
||
var titleCell = document.createElement('td');
|
||
titleCell.className = 'px-2 py-1 align-top whitespace-normal break-words w-full text-gray-500';
|
||
titleCell.textContent = self.title || '';
|
||
row.appendChild(titleCell);
|
||
|
||
var revisionCell = document.createElement('td');
|
||
revisionCell.className = 'px-2 py-1 align-top whitespace-nowrap text-center font-mono text-gray-500';
|
||
revisionCell.textContent = self.revision || '';
|
||
row.appendChild(revisionCell);
|
||
|
||
var statusCell = document.createElement('td');
|
||
statusCell.className = 'px-2 py-1 align-top whitespace-nowrap text-center font-mono text-gray-500';
|
||
statusCell.textContent = self.status || '';
|
||
row.appendChild(statusCell);
|
||
|
||
var extCell = document.createElement('td');
|
||
extCell.className = 'px-2 py-1 align-top whitespace-nowrap text-center font-mono text-gray-500';
|
||
extCell.textContent = 'html';
|
||
row.appendChild(extCell);
|
||
|
||
var sizeCell = document.createElement('td');
|
||
sizeCell.className = 'px-2 py-1 align-top whitespace-nowrap text-right font-mono text-gray-400';
|
||
sizeCell.textContent = '\u2014';
|
||
row.appendChild(sizeCell);
|
||
|
||
var hashCell = document.createElement('td');
|
||
hashCell.className = 'px-2 py-1 align-top font-mono text-[9px] whitespace-nowrap text-gray-400 italic';
|
||
hashCell.textContent = 'see above';
|
||
row.appendChild(hashCell);
|
||
|
||
tbody.appendChild(row);
|
||
}
|
||
|
||
filesModule.render = function render() {
|
||
var tbody = document.querySelector('table tbody');
|
||
if (!tbody) {
|
||
return;
|
||
}
|
||
tbody.innerHTML = '';
|
||
filesModule.sortFilesInPlace(app.data.files);
|
||
var filters = app.modules.filters ? app.modules.filters.getActiveFilters() : {};
|
||
var filtered = [];
|
||
(app.data.files || []).forEach(function (file, originalIndex) {
|
||
if (!app.modules.filters || app.modules.filters.fileMatchesFilters(file, filters)) {
|
||
filtered.push({ file: file, index: originalIndex });
|
||
}
|
||
});
|
||
|
||
// Row 0: transmittal self-entry (always pinned first)
|
||
renderSelfRow(tbody, 0);
|
||
|
||
var rowNum = 0;
|
||
filtered.forEach(function (entry) {
|
||
var file = entry.file;
|
||
var index = entry.index;
|
||
var row = document.createElement('tr');
|
||
rowNum++;
|
||
|
||
var numCell = document.createElement('td');
|
||
numCell.className = 'px-2 py-1 align-top text-center text-gray-400 whitespace-nowrap';
|
||
if (app.state.mode === 'edit') {
|
||
var delBtn = document.createElement('button');
|
||
delBtn.type = 'button';
|
||
delBtn.className = 'row-delete-btn';
|
||
delBtn.textContent = '\u00d7';
|
||
delBtn.title = 'Remove this file';
|
||
delBtn.dataset.fileIndex = String(index);
|
||
numCell.appendChild(delBtn);
|
||
var numSpan = document.createElement('span');
|
||
numSpan.textContent = String(rowNum);
|
||
numCell.appendChild(numSpan);
|
||
} else {
|
||
numCell.textContent = String(rowNum);
|
||
}
|
||
row.appendChild(numCell);
|
||
|
||
var trackingCell = document.createElement('td');
|
||
trackingCell.className = 'px-2 py-1 align-top whitespace-nowrap font-mono';
|
||
var link = document.createElement('a');
|
||
link.textContent = file.trackingNumber || '';
|
||
var relativePath = file.path || file.name || '';
|
||
if (relativePath) {
|
||
link.href = encodeURI(relativePath);
|
||
link.dataset.relativePath = relativePath;
|
||
} else {
|
||
link.href = '#';
|
||
}
|
||
link.title = relativePath || (file.name || '');
|
||
link.className = 'text-blue-600 hover:underline';
|
||
var ext = (file.extension || zddc.splitExtension(relativePath).extension);
|
||
if (app.constants.viewableExts.indexOf(ext) !== -1) {
|
||
link.target = '_blank';
|
||
link.rel = 'noopener';
|
||
} else {
|
||
var filename = (relativePath.split('/').pop() || file.name || 'download');
|
||
link.setAttribute('download', filename);
|
||
}
|
||
trackingCell.appendChild(link);
|
||
trackingCell.contentEditable = 'false';
|
||
trackingCell.dataset.index = String(index);
|
||
trackingCell.dataset.field = 'trackingNumber';
|
||
row.appendChild(trackingCell);
|
||
|
||
var titleCell = document.createElement('td');
|
||
titleCell.className = 'px-2 py-1 align-top whitespace-normal break-words w-full';
|
||
titleCell.textContent = file.title || '';
|
||
titleCell.contentEditable = (app.state.mode === 'edit').toString();
|
||
titleCell.setAttribute('role', 'textbox');
|
||
titleCell.setAttribute('aria-multiline', 'false');
|
||
titleCell.setAttribute('tabindex', '0');
|
||
titleCell.dataset.index = String(index);
|
||
titleCell.dataset.field = 'title';
|
||
row.appendChild(titleCell);
|
||
|
||
var revisionCell = document.createElement('td');
|
||
revisionCell.className = 'px-2 py-1 align-top whitespace-nowrap text-center font-mono';
|
||
revisionCell.textContent = file.revision || '';
|
||
revisionCell.contentEditable = (app.state.mode === 'edit').toString();
|
||
revisionCell.setAttribute('role', 'textbox');
|
||
revisionCell.setAttribute('aria-multiline', 'false');
|
||
revisionCell.setAttribute('tabindex', '0');
|
||
revisionCell.dataset.index = String(index);
|
||
revisionCell.dataset.field = 'revision';
|
||
row.appendChild(revisionCell);
|
||
|
||
var statusCell = document.createElement('td');
|
||
statusCell.className = 'px-2 py-1 align-top whitespace-nowrap text-center font-mono';
|
||
statusCell.textContent = file.status || '';
|
||
statusCell.contentEditable = (app.state.mode === 'edit').toString();
|
||
statusCell.setAttribute('role', 'textbox');
|
||
statusCell.setAttribute('aria-multiline', 'false');
|
||
statusCell.setAttribute('tabindex', '0');
|
||
statusCell.dataset.index = String(index);
|
||
statusCell.dataset.field = 'status';
|
||
row.appendChild(statusCell);
|
||
|
||
var extCell = document.createElement('td');
|
||
extCell.className = 'px-2 py-1 align-top whitespace-nowrap text-center font-mono';
|
||
extCell.textContent = (file.extension || '').toLowerCase();
|
||
extCell.contentEditable = 'false';
|
||
row.appendChild(extCell);
|
||
|
||
var sizeCell = document.createElement('td');
|
||
sizeCell.className = 'px-2 py-1 align-top whitespace-nowrap text-right font-mono';
|
||
var isUnmatched = !file.fileHandle && !file.sha256;
|
||
if (isUnmatched) {
|
||
sizeCell.textContent = '\u2014';
|
||
sizeCell.classList.add('text-gray-400');
|
||
} else {
|
||
var fileSizeValue = (file.fileSize != null ? file.fileSize : file.size);
|
||
sizeCell.textContent = util.formatFileSize(fileSizeValue);
|
||
}
|
||
sizeCell.contentEditable = 'false';
|
||
row.appendChild(sizeCell);
|
||
|
||
var hashCell = document.createElement('td');
|
||
hashCell.className = 'px-2 py-1 align-top font-mono text-[9px] whitespace-normal break-all leading-snug';
|
||
if (isUnmatched) {
|
||
hashCell.textContent = 'pending';
|
||
hashCell.classList.add('italic', 'text-gray-400');
|
||
} else {
|
||
hashCell.textContent = util.formatShortFileHash(file.sha256 || '');
|
||
}
|
||
row.appendChild(hashCell);
|
||
|
||
if (file._verifyResult) {
|
||
row.classList.add('verify-' + file._verifyResult);
|
||
}
|
||
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
if (!filtered.length) {
|
||
var placeholderRow = document.createElement('tr');
|
||
for (var i = 0; i < 8; i += 1) {
|
||
var cell = document.createElement('td');
|
||
cell.className = 'px-2 py-1 align-top';
|
||
placeholderRow.appendChild(cell);
|
||
}
|
||
tbody.appendChild(placeholderRow);
|
||
}
|
||
|
||
if (app.modules.filters && typeof app.modules.filters.refreshPlaceholders === 'function') {
|
||
app.modules.filters.refreshPlaceholders();
|
||
}
|
||
setupRowDropTargets();
|
||
};
|
||
|
||
// Undo state for row deletion
|
||
var _lastDeleted = null;
|
||
var _undoTimer = null;
|
||
|
||
function deleteFileRow(fileIndex) {
|
||
var file = app.data.files[fileIndex];
|
||
if (!file) { return; }
|
||
var setStatus = app.modules.data && app.modules.data.setStatus;
|
||
var label = (file.trackingNumber || '') + (file.title ? ' - ' + file.title : '');
|
||
|
||
// Store for undo
|
||
_lastDeleted = { file: file, index: fileIndex };
|
||
if (_undoTimer) { clearTimeout(_undoTimer); }
|
||
_undoTimer = setTimeout(function () { _lastDeleted = null; }, 10000);
|
||
|
||
app.data.files.splice(fileIndex, 1);
|
||
filesModule.updateFilesInJson(app.data.files);
|
||
filesModule.render();
|
||
app.state.apply();
|
||
app.markDirty();
|
||
|
||
if (setStatus) {
|
||
setStatus('Removed ' + (label || 'row') + ' — click here to undo', 'success');
|
||
// Attach one-time undo click handler to status bar
|
||
var statusEl = document.querySelector('#data-status');
|
||
if (statusEl) {
|
||
statusEl.style.cursor = 'pointer';
|
||
var cleanup = function () {
|
||
statusEl.removeEventListener('click', handler);
|
||
statusEl.style.cursor = '';
|
||
};
|
||
var handler = function () {
|
||
cleanup();
|
||
if (!_lastDeleted) { return; }
|
||
var d = _lastDeleted;
|
||
_lastDeleted = null;
|
||
if (_undoTimer) { clearTimeout(_undoTimer); _undoTimer = null; }
|
||
var idx = Math.min(d.index, app.data.files.length);
|
||
app.data.files.splice(idx, 0, d.file);
|
||
filesModule.updateFilesInJson(app.data.files);
|
||
filesModule.render();
|
||
app.state.apply();
|
||
app.markDirty();
|
||
if (setStatus) { setStatus('Restored ' + (label || 'row'), 'success'); }
|
||
};
|
||
// Clear cursor when undo expires
|
||
if (_undoTimer) { clearTimeout(_undoTimer); }
|
||
_undoTimer = setTimeout(function () { _lastDeleted = null; cleanup(); }, 10000);
|
||
statusEl.addEventListener('click', handler);
|
||
}
|
||
}
|
||
}
|
||
|
||
filesModule.setupTableEditing = function setupTableEditing() {
|
||
var tbody = document.querySelector('table tbody');
|
||
if (!tbody) {
|
||
return;
|
||
}
|
||
// Delegated handler for row delete buttons
|
||
tbody.addEventListener('click', function (event) {
|
||
var delBtn = event.target.closest('.row-delete-btn');
|
||
if (delBtn && delBtn.dataset.fileIndex !== undefined) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
deleteFileRow(Number(delBtn.dataset.fileIndex));
|
||
return;
|
||
}
|
||
});
|
||
// Click delegation for file preview
|
||
// Edit mode: always preview (relative paths don't work until HTML is saved)
|
||
// View mode: preview only when checkbox is checked and file source is loaded
|
||
tbody.addEventListener('click', function (event) {
|
||
var link = event.target.closest('a');
|
||
if (!link) {
|
||
return;
|
||
}
|
||
var cell = link.closest('td');
|
||
if (!cell || !cell.dataset.index) {
|
||
return;
|
||
}
|
||
var file = app.data.files[Number(cell.dataset.index)];
|
||
if (!file || !filesModule.hasFileSource || !filesModule.hasFileSource(file)) {
|
||
return;
|
||
}
|
||
var usePreview = (app.state.mode === 'edit') ||
|
||
(filesModule.isPreviewEnabled && filesModule.isPreviewEnabled()) ||
|
||
!!(file.fileHandle);
|
||
if (usePreview) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
filesModule.showFilePreview(file);
|
||
}
|
||
});
|
||
tbody.addEventListener('input', function (event) {
|
||
var target = event.target;
|
||
if (!(target instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
var index = target.dataset.index;
|
||
var field = target.dataset.field;
|
||
if (index === undefined || !field) {
|
||
return;
|
||
}
|
||
var entry = app.data.files[Number(index)];
|
||
if (!entry) {
|
||
return;
|
||
}
|
||
entry[field] = (target.textContent || '').replace(/\r?\n/g, ' ').trim();
|
||
filesModule.sortFilesInPlace(app.data.files);
|
||
filesModule.updateFilesInJson(app.data.files);
|
||
filesModule.render();
|
||
app.markDirty();
|
||
if (app.modules.liveDigest && app.modules.liveDigest.schedule) {
|
||
app.modules.liveDigest.schedule();
|
||
}
|
||
});
|
||
tbody.addEventListener('keydown', function (event) {
|
||
var target = event.target;
|
||
if (!(target instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
if (target.isContentEditable && event.key === 'Enter') {
|
||
event.preventDefault();
|
||
}
|
||
});
|
||
tbody.addEventListener('paste', function (event) {
|
||
var target = event.target;
|
||
if (!(target instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
if (!target.isContentEditable) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
var text = (event.clipboardData || window.clipboardData).getData('text');
|
||
var sanitized = (text || '').replace(/\r?\n/g, ' ');
|
||
try {
|
||
document.execCommand('insertText', false, sanitized);
|
||
} catch (err) {
|
||
target.textContent = (target.textContent || '') + sanitized;
|
||
}
|
||
});
|
||
};
|
||
|
||
filesModule.handleClear = function handleClear() {
|
||
if (app.state.mode !== 'edit') {
|
||
return;
|
||
}
|
||
if (filesModule.cleanupBlobUrls) {
|
||
filesModule.cleanupBlobUrls();
|
||
}
|
||
app.data.files = [];
|
||
app.data.selectedDirHandle = null;
|
||
filesModule.updateDirectoryIndicator('');
|
||
filesModule.updateFilesInJson([]);
|
||
filesModule.render();
|
||
app.state.apply();
|
||
app.markDirty();
|
||
};
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
var dom = app.dom;
|
||
var util = app.util;
|
||
var filesModule = app.modules.files;
|
||
|
||
// Blob URL cache keyed by file path
|
||
var blobCache = new Map();
|
||
|
||
// Current preview popup window reference
|
||
var previewWindow = null;
|
||
|
||
// Extensions that support rich in-browser preview
|
||
var PREVIEW_EXTENSIONS = ['pdf', 'docx', 'xlsx', 'xls'];
|
||
|
||
// Extensions that preview as images
|
||
var IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico'];
|
||
|
||
// Cache for lazily loaded CDN libraries
|
||
var loadedLibraries = new Map();
|
||
|
||
var MIME_TYPES = {
|
||
'pdf': 'application/pdf',
|
||
'doc': 'application/msword',
|
||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||
'xls': 'application/vnd.ms-excel',
|
||
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
'ppt': 'application/vnd.ms-powerpoint',
|
||
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||
'txt': 'text/plain',
|
||
'csv': 'text/csv',
|
||
'html': 'text/html',
|
||
'htm': 'text/html',
|
||
'xml': 'text/xml',
|
||
'json': 'application/json',
|
||
'jpg': 'image/jpeg',
|
||
'jpeg': 'image/jpeg',
|
||
'png': 'image/png',
|
||
'gif': 'image/gif',
|
||
'bmp': 'image/bmp',
|
||
'svg': 'image/svg+xml',
|
||
'webp': 'image/webp',
|
||
'ico': 'image/x-icon',
|
||
'zip': 'application/zip',
|
||
'mp4': 'video/mp4',
|
||
'mp3': 'audio/mpeg',
|
||
'wav': 'audio/wav',
|
||
'md': 'text/markdown'
|
||
};
|
||
|
||
function getMimeType(ext) {
|
||
return MIME_TYPES[(ext || '').toLowerCase()] || 'application/octet-stream';
|
||
}
|
||
|
||
function loadLibrary(url) {
|
||
if (loadedLibraries.has(url)) {
|
||
return loadedLibraries.get(url);
|
||
}
|
||
var promise = new Promise(function (resolve, reject) {
|
||
var script = document.createElement('script');
|
||
script.src = url;
|
||
script.onload = resolve;
|
||
script.onerror = function () {
|
||
reject(new Error('Failed to load library: ' + url));
|
||
};
|
||
document.head.appendChild(script);
|
||
});
|
||
loadedLibraries.set(url, promise);
|
||
return promise;
|
||
}
|
||
|
||
function isPreviewable(ext) {
|
||
var lower = (ext || '').toLowerCase();
|
||
return PREVIEW_EXTENSIONS.indexOf(lower) !== -1 || IMAGE_EXTENSIONS.indexOf(lower) !== -1;
|
||
}
|
||
|
||
function hasFileSource(file) {
|
||
return !!(file.fileHandle || file.zipEntry);
|
||
}
|
||
|
||
async function getFileArrayBuffer(file) {
|
||
if (file.fileHandle) {
|
||
var fileData = await file.fileHandle.getFile();
|
||
return fileData.arrayBuffer();
|
||
}
|
||
if (file.zipEntry) {
|
||
return file.zipEntry.async('arraybuffer');
|
||
}
|
||
throw new Error('No file source available');
|
||
}
|
||
|
||
async function getFileBlobUrl(file) {
|
||
var cacheKey = file.path || file.name || '';
|
||
if (blobCache.has(cacheKey)) {
|
||
return blobCache.get(cacheKey);
|
||
}
|
||
var arrayBuffer = await getFileArrayBuffer(file);
|
||
var mimeType = getMimeType(file.extension);
|
||
var blob = new Blob([arrayBuffer], { type: mimeType });
|
||
var url = URL.createObjectURL(blob);
|
||
blobCache.set(cacheKey, url);
|
||
return url;
|
||
}
|
||
|
||
function cleanupBlobUrls() {
|
||
blobCache.forEach(function (url) {
|
||
URL.revokeObjectURL(url);
|
||
});
|
||
blobCache.clear();
|
||
}
|
||
|
||
function buildPreviewHtml(file, url) {
|
||
var ext = (file.extension || '').toLowerCase();
|
||
var safeName = util.escapeHtml(file.name || file.path || 'file');
|
||
var safeHref = util.escapeHtmlAttribute(url);
|
||
|
||
var contentHtml;
|
||
if (ext === 'pdf') {
|
||
contentHtml = '<iframe src="' + safeHref + '"></iframe>';
|
||
} else {
|
||
contentHtml = '<div id="previewContent"><div class="loading">Loading preview...</div></div>';
|
||
}
|
||
|
||
return '<!DOCTYPE html>\n' +
|
||
'<html>\n' +
|
||
'<head>\n' +
|
||
'<title>' + safeName + ' - Preview</title>\n' +
|
||
'<style>\n' +
|
||
'* { margin: 0; padding: 0; box-sizing: border-box; }\n' +
|
||
'body { display: flex; flex-direction: column; height: 100vh; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }\n' +
|
||
'.toolbar { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; background: #f5f5f5; border-bottom: 1px solid #ddd; }\n' +
|
||
'.toolbar h1 { flex: 1; font-size: 0.95rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n' +
|
||
'.btn { padding: 0.4rem 0.8rem; font-size: 0.85rem; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer; }\n' +
|
||
'.btn:hover { background: #e8e8e8; }\n' +
|
||
'iframe { flex: 1; width: 100%; border: none; }\n' +
|
||
'#previewContent { flex: 1; overflow: auto; }\n' +
|
||
'.loading { display: flex; align-items: center; justify-content: center; height: 100%; color: #666; font-size: 1.1rem; }\n' +
|
||
'.docx-wrapper { padding: 1rem; }\n' +
|
||
'.xlsx-table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }\n' +
|
||
'.xlsx-table th, .xlsx-table td { border: 1px solid #ddd; padding: 0.35rem 0.5rem; text-align: left; white-space: nowrap; }\n' +
|
||
'.xlsx-table th { background: #f0f0f0; font-weight: 600; position: sticky; top: 0; }\n' +
|
||
'.xlsx-table tr:nth-child(even) { background: #fafafa; }\n' +
|
||
'.xlsx-table tr:hover { background: #f0f7ff; }\n' +
|
||
'.sheet-tabs { display: flex; gap: 0; border-bottom: 1px solid #ddd; background: #f5f5f5; }\n' +
|
||
'.sheet-tab { padding: 0.4rem 1rem; cursor: pointer; border: 1px solid transparent; border-bottom: none; font-size: 0.85rem; background: transparent; }\n' +
|
||
'.sheet-tab:hover { background: #e8e8e8; }\n' +
|
||
'.sheet-tab.active { background: white; border-color: #ddd; border-bottom-color: white; margin-bottom: -1px; font-weight: 500; }\n' +
|
||
'img.preview-image { max-width: 100%; max-height: 100%; object-fit: contain; margin: auto; display: block; }\n' +
|
||
'</style>\n' +
|
||
'</head>\n' +
|
||
'<body>\n' +
|
||
'<div class="toolbar">\n' +
|
||
'<h1>' + safeName + '</h1>\n' +
|
||
'<button class="btn" onclick="downloadFile()">Download</button>\n' +
|
||
'</div>\n' +
|
||
contentHtml + '\n' +
|
||
'<script>\n' +
|
||
'var blobUrl = "' + url.replace(/"/g, '\\"') + '";\n' +
|
||
'var fileName = "' + safeName.replace(/"/g, '\\"') + '";\n' +
|
||
'function downloadFile() {\n' +
|
||
' var a = document.createElement("a");\n' +
|
||
' a.href = blobUrl;\n' +
|
||
' a.download = fileName;\n' +
|
||
' document.body.appendChild(a);\n' +
|
||
' a.click();\n' +
|
||
' document.body.removeChild(a);\n' +
|
||
'}\n' +
|
||
'</' + 'script>\n' +
|
||
'</body>\n' +
|
||
'</html>';
|
||
}
|
||
|
||
async function renderDocxInWindow(file) {
|
||
var container = previewWindow.document.getElementById('previewContent');
|
||
if (!container) {
|
||
return;
|
||
}
|
||
try {
|
||
await loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js');
|
||
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
|
||
var arrayBuffer = await getFileArrayBuffer(file);
|
||
container.innerHTML = '';
|
||
await window.docx.renderAsync(arrayBuffer, container);
|
||
} catch (err) {
|
||
console.error('[transmittal] Error rendering DOCX:', err);
|
||
container.innerHTML = '<div class="loading">Error rendering DOCX: ' + util.escapeHtml(err.message || '') + '<br>Click Download to view in Word.</div>';
|
||
}
|
||
}
|
||
|
||
async function renderXlsxInWindow(file) {
|
||
var container = previewWindow.document.getElementById('previewContent');
|
||
if (!container) {
|
||
return;
|
||
}
|
||
try {
|
||
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
|
||
var arrayBuffer = await getFileArrayBuffer(file);
|
||
var workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||
container.innerHTML = '';
|
||
|
||
var tableContainer = previewWindow.document.createElement('div');
|
||
tableContainer.style.flex = '1';
|
||
tableContainer.style.overflow = 'auto';
|
||
|
||
if (workbook.SheetNames.length > 1) {
|
||
var tabs = previewWindow.document.createElement('div');
|
||
tabs.className = 'sheet-tabs';
|
||
workbook.SheetNames.forEach(function (name, i) {
|
||
var tab = previewWindow.document.createElement('button');
|
||
tab.className = 'sheet-tab' + (i === 0 ? ' active' : '');
|
||
tab.textContent = name;
|
||
tab.addEventListener('click', function () {
|
||
tabs.querySelectorAll('.sheet-tab').forEach(function (t) {
|
||
t.classList.remove('active');
|
||
});
|
||
tab.classList.add('active');
|
||
renderSheet(workbook, name, tableContainer);
|
||
});
|
||
tabs.appendChild(tab);
|
||
});
|
||
container.appendChild(tabs);
|
||
}
|
||
|
||
container.appendChild(tableContainer);
|
||
renderSheet(workbook, workbook.SheetNames[0], tableContainer);
|
||
} catch (err) {
|
||
console.error('[transmittal] Error rendering XLSX:', err);
|
||
container.innerHTML = '<div class="loading">Error rendering spreadsheet: ' + util.escapeHtml(err.message || '') + '<br>Click Download to view in Excel.</div>';
|
||
}
|
||
}
|
||
|
||
function renderSheet(workbook, sheetName, container) {
|
||
var sheet = workbook.Sheets[sheetName];
|
||
var html = XLSX.utils.sheet_to_html(sheet, { editable: false });
|
||
container.innerHTML = html;
|
||
var table = container.querySelector('table');
|
||
if (table) {
|
||
table.className = 'xlsx-table';
|
||
}
|
||
}
|
||
|
||
async function renderImageInWindow(file, url) {
|
||
var container = previewWindow.document.getElementById('previewContent');
|
||
if (!container) {
|
||
return;
|
||
}
|
||
container.innerHTML = '';
|
||
var img = previewWindow.document.createElement('img');
|
||
img.className = 'preview-image';
|
||
img.src = url;
|
||
img.alt = file.name || '';
|
||
container.appendChild(img);
|
||
}
|
||
|
||
async function showFilePreview(file) {
|
||
var ext = (file.extension || '').toLowerCase();
|
||
try {
|
||
var url = await getFileBlobUrl(file);
|
||
var html = buildPreviewHtml(file, url);
|
||
|
||
if (previewWindow && !previewWindow.closed) {
|
||
previewWindow.document.open();
|
||
previewWindow.document.write(html);
|
||
previewWindow.document.close();
|
||
previewWindow.focus();
|
||
} else {
|
||
var width = Math.round(screen.width * 0.6);
|
||
var height = Math.round(screen.height * 0.8);
|
||
var left = Math.round((screen.width - width) / 2);
|
||
var top = Math.round((screen.height - height) / 2);
|
||
previewWindow = window.open('', 'filePreview',
|
||
'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',resizable=yes,scrollbars=yes');
|
||
if (!previewWindow) {
|
||
window.open(url, '_blank');
|
||
return;
|
||
}
|
||
previewWindow.document.write(html);
|
||
previewWindow.document.close();
|
||
previewWindow.focus();
|
||
}
|
||
|
||
if (ext === 'docx') {
|
||
await renderDocxInWindow(file);
|
||
} else if (ext === 'xlsx' || ext === 'xls') {
|
||
await renderXlsxInWindow(file);
|
||
} else if (IMAGE_EXTENSIONS.indexOf(ext) !== -1) {
|
||
await renderImageInWindow(file, url);
|
||
}
|
||
} catch (err) {
|
||
console.error('[transmittal] Error loading file preview:', err);
|
||
alert('Error loading preview: ' + (err && err.message ? err.message : err));
|
||
}
|
||
}
|
||
|
||
// --- Load files for preview (directory or ZIP) ---
|
||
|
||
function updatePreviewStatus(text) {
|
||
var el = dom.qs('#preview-status');
|
||
if (el) {
|
||
el.textContent = text;
|
||
}
|
||
}
|
||
|
||
function matchFilesToSources(sourceEntries) {
|
||
var files = app.data.files || [];
|
||
if (!files.length || !sourceEntries.length) {
|
||
return 0;
|
||
}
|
||
|
||
// Build lookup by relative path and by filename
|
||
var byPath = new Map();
|
||
var byName = new Map();
|
||
sourceEntries.forEach(function (entry) {
|
||
if (entry.path) {
|
||
byPath.set(entry.path, entry);
|
||
}
|
||
if (entry.name) {
|
||
// Only use filename match if unique
|
||
if (byName.has(entry.name)) {
|
||
byName.set(entry.name, null); // Mark as ambiguous
|
||
} else {
|
||
byName.set(entry.name, entry);
|
||
}
|
||
}
|
||
});
|
||
|
||
var matched = 0;
|
||
files.forEach(function (file) {
|
||
var source = null;
|
||
// Try path match first
|
||
if (file.path && byPath.has(file.path)) {
|
||
source = byPath.get(file.path);
|
||
}
|
||
// Fall back to name match
|
||
if (!source && file.name) {
|
||
var nameMatch = byName.get(file.name);
|
||
if (nameMatch) {
|
||
source = nameMatch;
|
||
}
|
||
}
|
||
if (source) {
|
||
if (source.fileHandle) {
|
||
file.fileHandle = source.fileHandle;
|
||
}
|
||
if (source.zipEntry) {
|
||
file.zipEntry = source.zipEntry;
|
||
}
|
||
matched += 1;
|
||
}
|
||
});
|
||
return matched;
|
||
}
|
||
|
||
async function loadFromDirectory() {
|
||
if (typeof window.showDirectoryPicker !== 'function') {
|
||
alert('Your browser does not support directory selection. Try Chrome or Edge.');
|
||
return;
|
||
}
|
||
var dirHandle;
|
||
try {
|
||
dirHandle = await window.showDirectoryPicker();
|
||
} catch (err) {
|
||
if (err && (err.name === 'AbortError' || err.name === 'NotAllowedError')) {
|
||
return;
|
||
}
|
||
throw err;
|
||
}
|
||
updatePreviewStatus('Scanning directory...');
|
||
var entries = await filesModule.collectDirectoryEntries(dirHandle);
|
||
var sourceEntries = entries.map(function (entry) {
|
||
return { path: entry.path, name: entry.name, fileHandle: entry.handle };
|
||
});
|
||
var matched = matchFilesToSources(sourceEntries);
|
||
if (matched > 0) {
|
||
filesLoadedForPreview = true;
|
||
updatePreviewStatus('from "' + dirHandle.name + '" — ' + matched + ' of ' + (app.data.files || []).length + ' files matched');
|
||
filesModule.render();
|
||
} else {
|
||
updatePreviewStatus('No matching files found in "' + dirHandle.name + '"');
|
||
}
|
||
}
|
||
|
||
async function loadFromZip() {
|
||
return new Promise(function (resolve) {
|
||
var input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = '.zip';
|
||
input.addEventListener('change', async function () {
|
||
var zipFile = input.files && input.files[0];
|
||
if (!zipFile) {
|
||
resolve();
|
||
return;
|
||
}
|
||
try {
|
||
updatePreviewStatus('Loading ZIP...');
|
||
await loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js');
|
||
var arrayBuffer = await zipFile.arrayBuffer();
|
||
var zip = await JSZip.loadAsync(arrayBuffer);
|
||
var sourceEntries = [];
|
||
zip.forEach(function (relativePath, zipEntry) {
|
||
if (zipEntry.dir) {
|
||
return;
|
||
}
|
||
// Strip the outermost folder if all entries share one
|
||
var name = relativePath.split('/').pop();
|
||
sourceEntries.push({
|
||
path: relativePath,
|
||
name: name,
|
||
zipEntry: zipEntry
|
||
});
|
||
});
|
||
|
||
// Try stripping common prefix for better path matching
|
||
if (sourceEntries.length > 0) {
|
||
var firstPath = sourceEntries[0].path;
|
||
var prefix = firstPath.indexOf('/') !== -1 ? firstPath.substring(0, firstPath.indexOf('/') + 1) : '';
|
||
if (prefix) {
|
||
var allSharePrefix = sourceEntries.every(function (e) {
|
||
return e.path.indexOf(prefix) === 0;
|
||
});
|
||
if (allSharePrefix) {
|
||
sourceEntries.forEach(function (e) {
|
||
e.path = e.path.substring(prefix.length);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
var matched = matchFilesToSources(sourceEntries);
|
||
if (matched > 0) {
|
||
filesLoadedForPreview = true;
|
||
updatePreviewStatus('from "' + zipFile.name + '" — ' + matched + ' of ' + (app.data.files || []).length + ' files matched');
|
||
filesModule.render();
|
||
} else {
|
||
updatePreviewStatus('No matching files found in "' + zipFile.name + '"');
|
||
}
|
||
} catch (err) {
|
||
console.error('[transmittal] Error loading ZIP:', err);
|
||
updatePreviewStatus('Failed to load ZIP: ' + (err && err.message ? err.message : err));
|
||
}
|
||
resolve();
|
||
});
|
||
input.click();
|
||
});
|
||
}
|
||
|
||
// Track whether files have been loaded for preview in view mode
|
||
var filesLoadedForPreview = false;
|
||
|
||
function isPreviewEnabled() {
|
||
var checkbox = dom.qs('#preview-toggle');
|
||
return checkbox && checkbox.checked;
|
||
}
|
||
|
||
function syncCheckboxToMode() {
|
||
var checkbox = dom.qs('#preview-toggle');
|
||
if (!checkbox) {
|
||
return;
|
||
}
|
||
if (app.state.mode === 'edit') {
|
||
checkbox.checked = true;
|
||
checkbox.disabled = true;
|
||
updatePreviewStatus('');
|
||
} else {
|
||
checkbox.disabled = false;
|
||
if (!filesLoadedForPreview) {
|
||
checkbox.checked = false;
|
||
updatePreviewStatus('');
|
||
}
|
||
}
|
||
}
|
||
|
||
function showSourcePicker() {
|
||
var bar = dom.qs('#preview-bar');
|
||
var existing = dom.qs('#preview-load-menu');
|
||
if (existing) {
|
||
existing.remove();
|
||
return;
|
||
}
|
||
var menu = document.createElement('div');
|
||
menu.id = 'preview-load-menu';
|
||
menu.style.cssText = 'position:absolute;z-index:50;background:white;border:1px solid #ccc;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,0.15);padding:0.25rem 0;min-width:160px;';
|
||
|
||
function addOption(label, handler) {
|
||
var btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.textContent = label;
|
||
btn.style.cssText = 'display:block;width:100%;text-align:left;padding:0.4rem 0.75rem;border:none;background:none;cursor:pointer;font-size:0.8rem;';
|
||
btn.addEventListener('mouseenter', function () { btn.style.background = '#f0f0f0'; });
|
||
btn.addEventListener('mouseleave', function () { btn.style.background = 'none'; });
|
||
btn.addEventListener('click', function () {
|
||
menu.remove();
|
||
handler();
|
||
});
|
||
menu.appendChild(btn);
|
||
}
|
||
|
||
addOption('Select Directory', function () {
|
||
loadFromDirectory().then(function () {
|
||
if (!filesLoadedForPreview) {
|
||
uncheckPreview();
|
||
}
|
||
}).catch(function (err) {
|
||
console.error('[transmittal] loadFromDirectory failed', err);
|
||
updatePreviewStatus('Failed: ' + (err && err.message ? err.message : err));
|
||
uncheckPreview();
|
||
});
|
||
});
|
||
|
||
addOption('Select ZIP File', function () {
|
||
loadFromZip().then(function () {
|
||
if (!filesLoadedForPreview) {
|
||
uncheckPreview();
|
||
}
|
||
}).catch(function (err) {
|
||
console.error('[transmittal] loadFromZip failed', err);
|
||
updatePreviewStatus('Failed: ' + (err && err.message ? err.message : err));
|
||
uncheckPreview();
|
||
});
|
||
});
|
||
|
||
if (bar) {
|
||
bar.style.position = 'relative';
|
||
menu.style.top = '1.5rem';
|
||
menu.style.left = '0';
|
||
bar.appendChild(menu);
|
||
}
|
||
|
||
function closeMenu(e) {
|
||
if (!menu.contains(e.target)) {
|
||
menu.remove();
|
||
document.removeEventListener('click', closeMenu, true);
|
||
}
|
||
}
|
||
setTimeout(function () {
|
||
document.addEventListener('click', closeMenu, true);
|
||
}, 0);
|
||
}
|
||
|
||
function uncheckPreview() {
|
||
var checkbox = dom.qs('#preview-toggle');
|
||
if (checkbox) {
|
||
checkbox.checked = false;
|
||
}
|
||
}
|
||
|
||
function bindPreviewBar() {
|
||
var checkbox = dom.qs('#preview-toggle');
|
||
if (!checkbox) {
|
||
return;
|
||
}
|
||
checkbox.addEventListener('change', function () {
|
||
if (app.state.mode === 'edit') {
|
||
checkbox.checked = true;
|
||
return;
|
||
}
|
||
if (checkbox.checked) {
|
||
if (!filesLoadedForPreview) {
|
||
showSourcePicker();
|
||
}
|
||
} else {
|
||
updatePreviewStatus('');
|
||
}
|
||
});
|
||
syncCheckboxToMode();
|
||
}
|
||
|
||
// Expose on filesModule
|
||
filesModule.isPreviewable = isPreviewable;
|
||
filesModule.hasFileSource = hasFileSource;
|
||
filesModule.isPreviewEnabled = isPreviewEnabled;
|
||
filesModule.syncPreviewCheckbox = syncCheckboxToMode;
|
||
filesModule.showFilePreview = showFilePreview;
|
||
filesModule.cleanupBlobUrls = cleanupBlobUrls;
|
||
filesModule.getFileArrayBuffer = getFileArrayBuffer;
|
||
filesModule.getFileBlobUrl = getFileBlobUrl;
|
||
filesModule.loadLibrary = loadLibrary;
|
||
filesModule.getMimeType = getMimeType;
|
||
|
||
app.registerInit(function () {
|
||
bindPreviewBar();
|
||
window.addEventListener('beforeunload', cleanupBlobUrls);
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// Escape a string for use in a RegExp (literal match)
|
||
function escapeRegex(str) {
|
||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
// Build regex pattern at parse time based on anchors
|
||
function compilePattern(raw, anchorStart, anchorEnd) {
|
||
var src = (anchorStart ? '^' : '') + raw + (anchorEnd ? '$' : '');
|
||
try {
|
||
return new RegExp(src, 'i');
|
||
} catch (e) {
|
||
// Invalid regex — escape and retry (always succeeds)
|
||
var safe = (anchorStart ? '^' : '') + escapeRegex(raw) + (anchorEnd ? '$' : '');
|
||
return new RegExp(safe, 'i');
|
||
}
|
||
}
|
||
|
||
// Parse a single token string into a node
|
||
function parseToken(token) {
|
||
var s = token;
|
||
var negate = false;
|
||
var anchorStart = false;
|
||
var anchorEnd = false;
|
||
|
||
if (s.charAt(0) === '!') {
|
||
negate = true;
|
||
s = s.slice(1);
|
||
}
|
||
if (s.charAt(0) === '^') {
|
||
anchorStart = true;
|
||
s = s.slice(1);
|
||
}
|
||
if (s.length > 0 && s.charAt(s.length - 1) === '$') {
|
||
anchorEnd = true;
|
||
s = s.slice(0, -1);
|
||
}
|
||
|
||
if (s === '') return null;
|
||
|
||
// bare * (possibly after stripping !) → wildcard-all or wildcard-none
|
||
if (s === '*' && !anchorStart && !anchorEnd) {
|
||
return negate ? null : { type: 'wildcard-all' };
|
||
}
|
||
|
||
var re = compilePattern(s, anchorStart, anchorEnd);
|
||
return { type: negate ? 'no-match' : 'match', re: re };
|
||
}
|
||
|
||
// Parse expression string into AST array
|
||
function parse(expression) {
|
||
if (!expression || typeof expression !== 'string') return [];
|
||
var trimmed = expression.trim();
|
||
if (trimmed === '') return [];
|
||
if (trimmed === '*') return [{ type: 'wildcard-all' }];
|
||
|
||
var ast = [];
|
||
var i = 0;
|
||
var len = trimmed.length;
|
||
|
||
while (i < len) {
|
||
var ch = trimmed.charAt(i);
|
||
|
||
if (ch === '(') {
|
||
var depth = 1;
|
||
var j = i + 1;
|
||
while (j < len && depth > 0) {
|
||
if (trimmed.charAt(j) === '(') depth++;
|
||
else if (trimmed.charAt(j) === ')') depth--;
|
||
j++;
|
||
}
|
||
var innerAst = parse(trimmed.slice(i + 1, j - 1));
|
||
if (innerAst.length === 1) {
|
||
ast.push(innerAst[0]);
|
||
} else if (innerAst.length > 1) {
|
||
for (var k = 0; k < innerAst.length; k++) ast.push(innerAst[k]);
|
||
}
|
||
i = j;
|
||
} else if (ch === '|') {
|
||
ast.push({ type: 'pipe' });
|
||
i++;
|
||
} else if (ch === ' ') {
|
||
i++;
|
||
} else {
|
||
var j = i;
|
||
while (j < len) {
|
||
var c = trimmed.charAt(j);
|
||
if (c === ' ' || c === '(' || c === '|' || c === ')') break;
|
||
j++;
|
||
}
|
||
var token = trimmed.slice(i, j);
|
||
if (token.length > 0) {
|
||
var node = parseToken(token);
|
||
if (node !== null) ast.push(node);
|
||
}
|
||
i = j;
|
||
}
|
||
}
|
||
|
||
// Group pipes into OR nodes
|
||
var hasPipe = false;
|
||
var branches = [[]];
|
||
for (var l = 0; l < ast.length; l++) {
|
||
if (ast[l].type === 'pipe') {
|
||
hasPipe = true;
|
||
branches.push([]);
|
||
} else {
|
||
branches[branches.length - 1].push(ast[l]);
|
||
}
|
||
}
|
||
branches = branches.filter(function(b) { return b.length > 0; });
|
||
|
||
if (!hasPipe) {
|
||
return ast.filter(function(n) { return n.type !== 'pipe'; });
|
||
}
|
||
|
||
var orNodes = branches.map(function(branch) {
|
||
if (branch.length === 1) return branch[0];
|
||
return { type: 'and', nodes: branch };
|
||
});
|
||
return [{ type: 'or', nodes: orNodes }];
|
||
}
|
||
|
||
// Check if a single node matches the value
|
||
function nodeMatches(node, value) {
|
||
switch (node.type) {
|
||
case 'wildcard-all': return true;
|
||
case 'match': return node.re.test(value);
|
||
case 'no-match': return !node.re.test(value);
|
||
case 'or':
|
||
for (var i = 0; i < node.nodes.length; i++) {
|
||
if (nodeMatches(node.nodes[i], value)) return true;
|
||
}
|
||
return false;
|
||
case 'and':
|
||
for (var i = 0; i < node.nodes.length; i++) {
|
||
if (!nodeMatches(node.nodes[i], value)) return false;
|
||
}
|
||
return true;
|
||
default: return false;
|
||
}
|
||
}
|
||
|
||
// Evaluate AST against value
|
||
function matches(value, ast) {
|
||
if (!ast || ast.length === 0) return true;
|
||
var v = String(value); // no forced lowercase — regex has 'i' flag
|
||
for (var i = 0; i < ast.length; i++) {
|
||
if (!nodeMatches(ast[i], v)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
if (!window.zddc) {
|
||
throw new Error('shared/zddc-filter.js: window.zddc must be loaded first');
|
||
}
|
||
window.zddc.filter = { parse: parse, matches: matches };
|
||
})();
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const filters = app.modules.filters = {};
|
||
const dom = app.dom;
|
||
|
||
filters.fieldString = function fieldString(file, field) {
|
||
let value = '';
|
||
switch (field) {
|
||
case 'trackingNumber': value = file.trackingNumber || ''; break;
|
||
case 'title': value = file.title || ''; break;
|
||
case 'revision': value = file.revision || ''; break;
|
||
case 'status': value = file.status || ''; break;
|
||
case 'extension': value = file.extension || ''; break;
|
||
case 'sha256': value = file.sha256 || ''; break;
|
||
default: value = '';
|
||
}
|
||
return String(value).toLowerCase();
|
||
};
|
||
|
||
filters.getActiveFilters = function getActiveFilters() {
|
||
const active = {};
|
||
const inputs = document.querySelectorAll('thead input[data-filter-field]');
|
||
inputs.forEach(function (input) {
|
||
const field = input.getAttribute('data-filter-field');
|
||
const query = (input.value || '').trim();
|
||
if (!query) return;
|
||
active[field] = window.zddc.filter.parse(query);
|
||
});
|
||
return active;
|
||
};
|
||
|
||
filters.fileMatchesFilters = function fileMatchesFilters(file, activeFilters) {
|
||
const fields = Object.keys(activeFilters);
|
||
for (let i = 0; i < fields.length; i++) {
|
||
const field = fields[i];
|
||
const ast = activeFilters[field];
|
||
const value = filters.fieldString(file, field);
|
||
if (!window.zddc.filter.matches(value, ast)) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
};
|
||
|
||
filters.refreshPlaceholders = function refreshPlaceholders() {
|
||
const inputs = document.querySelectorAll('thead input[data-filter-field]');
|
||
inputs.forEach(function (input) {
|
||
const empty = !(input.value || '').trim().length;
|
||
input.toggleAttribute('data-empty', empty);
|
||
});
|
||
};
|
||
|
||
filters.bindFilters = function bindFilters() {
|
||
const head = document.querySelector('thead');
|
||
if (!head) return;
|
||
|
||
head.addEventListener('input', function (event) {
|
||
const target = event.target;
|
||
if (target && target.getAttribute && target.getAttribute('data-filter-field')) {
|
||
filters.refreshPlaceholders();
|
||
if (app.modules.files && typeof app.modules.files.render === 'function') {
|
||
app.modules.files.render();
|
||
}
|
||
}
|
||
});
|
||
|
||
head.addEventListener('keydown', function (event) {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLElement)) return;
|
||
if (!target.getAttribute || !target.getAttribute('data-filter-field')) return;
|
||
if (event.key === 'Escape') {
|
||
target.value = '';
|
||
filters.refreshPlaceholders();
|
||
if (app.modules.files && typeof app.modules.files.render === 'function') {
|
||
app.modules.files.render();
|
||
}
|
||
event.preventDefault();
|
||
} else if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
const inputs = Array.from(head.querySelectorAll('input[data-filter-field]'));
|
||
const index = inputs.indexOf(target);
|
||
if (index !== -1) {
|
||
const next = inputs[(index + 1) % inputs.length];
|
||
if (next && typeof next.focus === 'function') next.focus();
|
||
}
|
||
}
|
||
});
|
||
|
||
filters.refreshPlaceholders();
|
||
};
|
||
|
||
app.registerInit(function () {
|
||
filters.bindFilters();
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const markdown = app.modules.markdown = {};
|
||
const dom = app.dom;
|
||
|
||
var escapeHtml = app.util.escapeHtml;
|
||
|
||
function renderInline(text) {
|
||
if (!text) {
|
||
return '';
|
||
}
|
||
let rendered = escapeHtml(text);
|
||
const codePlaceholders = [];
|
||
rendered = rendered.replace(/`([^`]+)`/g, function (_, code) {
|
||
codePlaceholders.push('<code>' + escapeHtml(code) + '</code>');
|
||
return '\u0000C' + (codePlaceholders.length - 1) + '\u0000';
|
||
});
|
||
rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, label, url) {
|
||
const trimmedUrl = url.trim();
|
||
const allowed = /^(https?:\/\/|mailto:|tel:|\/|\.{1,2}\/)/i.test(trimmedUrl);
|
||
if (!allowed) {
|
||
return '[' + label + '](' + url + ')';
|
||
}
|
||
const safeUrl = trimmedUrl.replace(/\s+/g, '%20').replace(/"/g, '%22');
|
||
return '<a href="' + safeUrl + '" target="_blank" rel="noopener">' + escapeHtml(label) + '</a>';
|
||
});
|
||
rendered = rendered.replace(/(^|\s)((https?:\/\/)[^\s<]+)/gi, function (_, prefix, url) {
|
||
const safe = url.replace(/"/g, '%22');
|
||
return prefix + '<a href="' + safe + '" target="_blank" rel="noopener">' + url + '</a>';
|
||
});
|
||
rendered = rendered
|
||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/__([^_]+)__/g, '<strong>$1</strong>')
|
||
.replace(/(^|\W)\*([^*]+)\*(?=\W|$)/g, '$1<em>$2</em>')
|
||
.replace(/(^|\W)_([^_]+)_(?=\W|$)/g, '$1<em>$2</em>')
|
||
.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
||
rendered = rendered.replace(/\u0000C(\d+)\u0000/g, function (_, index) {
|
||
return codePlaceholders[Number(index)] || '';
|
||
});
|
||
return rendered;
|
||
}
|
||
|
||
function leadingIndent(text) {
|
||
const expanded = text.replace(/\t/g, ' ');
|
||
const match = expanded.match(/^(\s*)/);
|
||
return match ? match[1].length : 0;
|
||
}
|
||
|
||
markdown.render = function renderMarkdownBasic(markdownText) {
|
||
const lines = (markdownText || '').replace(/\r\n?/g, '\n').split('\n');
|
||
const output = [];
|
||
let inCode = false;
|
||
let codeBuffer = [];
|
||
const listStack = [];
|
||
const tableSeparator = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
|
||
|
||
function flushCode() {
|
||
if (!inCode) {
|
||
return;
|
||
}
|
||
output.push('<pre><code>' + escapeHtml(codeBuffer.join('\n')) + '</code></pre>');
|
||
inCode = false;
|
||
codeBuffer = [];
|
||
}
|
||
|
||
function closeListsTo(indent) {
|
||
while (listStack.length && listStack[listStack.length - 1].indent > indent) {
|
||
output.push('</' + listStack.pop().type + '>');
|
||
}
|
||
}
|
||
|
||
function closeAllLists() {
|
||
closeListsTo(-1);
|
||
}
|
||
|
||
function openList(type, indent) {
|
||
listStack.push({ type, indent });
|
||
output.push('<' + type + '>');
|
||
}
|
||
|
||
function splitCells(row) {
|
||
const trimmed = row.trim().replace(/^\|/, '').replace(/\|$/, '');
|
||
return trimmed.split('|').map(function (cell) {
|
||
return cell.trim();
|
||
});
|
||
}
|
||
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const raw = lines[index];
|
||
const line = raw;
|
||
if (/^```/.test(line)) {
|
||
if (inCode) {
|
||
flushCode();
|
||
} else {
|
||
inCode = true;
|
||
codeBuffer = [];
|
||
}
|
||
continue;
|
||
}
|
||
if (inCode) {
|
||
codeBuffer.push(raw);
|
||
continue;
|
||
}
|
||
if (!line.trim()) {
|
||
closeAllLists();
|
||
continue;
|
||
}
|
||
if (/\|/.test(line) && index + 1 < lines.length && tableSeparator.test(lines[index + 1])) {
|
||
closeAllLists();
|
||
const headerCells = splitCells(line);
|
||
index += 2;
|
||
const body = [];
|
||
while (index < lines.length && /\|/.test(lines[index]) && lines[index].trim()) {
|
||
body.push(splitCells(lines[index]));
|
||
index += 1;
|
||
}
|
||
index -= 1;
|
||
const thead = '<thead><tr>' + headerCells.map(function (cell) {
|
||
return '<th>' + renderInline(cell) + '</th>';
|
||
}).join('') + '</tr></thead>';
|
||
const tbody = '<tbody>' + body.map(function (rowCells) {
|
||
return '<tr>' + rowCells.map(function (cell) {
|
||
return '<td>' + renderInline(cell) + '</td>';
|
||
}).join('') + '</tr>';
|
||
}).join('') + '</tbody>';
|
||
output.push('<table>' + thead + tbody + '</table>');
|
||
continue;
|
||
}
|
||
if (/^\s*(?:---|\*\*\*|___)\s*$/.test(line)) {
|
||
closeAllLists();
|
||
output.push('<hr/>');
|
||
continue;
|
||
}
|
||
if (/^\s*>\s?/.test(line)) {
|
||
closeAllLists();
|
||
const quoteLines = [];
|
||
while (index < lines.length && /^\s*>\s?/.test(lines[index])) {
|
||
quoteLines.push(lines[index].replace(/^\s*>\s?/, ''));
|
||
index += 1;
|
||
}
|
||
index -= 1;
|
||
const html = quoteLines.map(function (text) {
|
||
return '<p>' + renderInline(text) + '</p>';
|
||
}).join('\n');
|
||
output.push('<blockquote>' + html + '</blockquote>');
|
||
continue;
|
||
}
|
||
let match = line.match(/^(#{1,6})\s+(.*)$/);
|
||
if (match) {
|
||
closeAllLists();
|
||
const level = match[1].length;
|
||
const text = renderInline(match[2].trim());
|
||
output.push('<h' + level + '>' + text + '</h' + level + '>');
|
||
continue;
|
||
}
|
||
match = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
||
if (match) {
|
||
const indent = leadingIndent(match[1]);
|
||
const type = 'ul';
|
||
if (!listStack.length || listStack[listStack.length - 1].indent < indent) {
|
||
openList(type, indent);
|
||
} else {
|
||
closeListsTo(indent);
|
||
if (!listStack.length || listStack[listStack.length - 1].type !== type || listStack[listStack.length - 1].indent !== indent) {
|
||
openList(type, indent);
|
||
}
|
||
}
|
||
output.push('<li>' + renderInline(match[2].trim()) + '</li>');
|
||
continue;
|
||
}
|
||
match = line.match(/^(\s*)\d+\.\s+(.*)$/);
|
||
if (match) {
|
||
const indent = leadingIndent(match[1]);
|
||
const type = 'ol';
|
||
if (!listStack.length || listStack[listStack.length - 1].indent < indent) {
|
||
openList(type, indent);
|
||
} else {
|
||
closeListsTo(indent);
|
||
if (!listStack.length || listStack[listStack.length - 1].type !== type || listStack[listStack.length - 1].indent !== indent) {
|
||
openList(type, indent);
|
||
}
|
||
}
|
||
output.push('<li>' + renderInline(match[2].trim()) + '</li>');
|
||
continue;
|
||
}
|
||
closeAllLists();
|
||
output.push('<p>' + renderInline(line) + '</p>');
|
||
}
|
||
|
||
closeAllLists();
|
||
flushCode();
|
||
return output.join('\n');
|
||
};
|
||
|
||
markdown.refresh = function refreshPreview() {
|
||
const textarea = dom.qs('#remarks');
|
||
const target = dom.qs('#remarks-render');
|
||
if (!textarea || !target) {
|
||
return;
|
||
}
|
||
target.innerHTML = markdown.render(textarea.value || '');
|
||
};
|
||
|
||
markdown.bindRemarksLivePreview = function bindRemarksLivePreview() {
|
||
const textarea = dom.qs('#remarks');
|
||
if (!textarea) {
|
||
return;
|
||
}
|
||
textarea.addEventListener('input', function () {
|
||
if (app.state.mode === 'edit') {
|
||
markdown.refresh();
|
||
}
|
||
});
|
||
markdown.refresh();
|
||
};
|
||
|
||
app.registerInit(function () {
|
||
markdown.bindRemarksLivePreview();
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
var dom = app.dom;
|
||
var markdown = app.modules.markdown;
|
||
|
||
var editor = app.modules.markdownEditor = {};
|
||
var inputEl = null; // plain textarea
|
||
var toolbarEl = null; // button bar
|
||
var wrapperEl = null; // container for all editor elements
|
||
var initialized = false;
|
||
var renderClickBound = false;
|
||
var outsideClickBound = false;
|
||
|
||
// ── Textarea helpers ──────────────────────────────────────────────
|
||
|
||
function syncToHiddenTextarea() {
|
||
var remarks = dom.qs('#remarks');
|
||
if (remarks && inputEl) { remarks.value = inputEl.value; }
|
||
}
|
||
|
||
function insertText(before, after, placeholder) {
|
||
if (!inputEl) { return; }
|
||
inputEl.focus();
|
||
var start = inputEl.selectionStart;
|
||
var end = inputEl.selectionEnd;
|
||
var selected = inputEl.value.substring(start, end);
|
||
var insert = selected || placeholder || '';
|
||
var full = before + insert + (after || '');
|
||
// execCommand preserves undo stack in most browsers
|
||
document.execCommand('insertText', false, full);
|
||
// If we used placeholder, select it for easy replacement
|
||
if (!selected && insert) {
|
||
inputEl.selectionStart = start + before.length;
|
||
inputEl.selectionEnd = start + before.length + insert.length;
|
||
}
|
||
}
|
||
|
||
// ── Button bar ────────────────────────────────────────────────────
|
||
|
||
var buttons = [
|
||
{ label: 'B', title: 'Bold', wrap: ['**', '**'], placeholder: 'bold' },
|
||
{ label: 'I', title: 'Italic', wrap: ['*', '*'], placeholder: 'italic' },
|
||
{ label: 'H', title: 'Heading', wrap: ['## ', ''], placeholder: 'heading' },
|
||
{ label: '\u2022', title: 'Bullet list', wrap: ['- ', ''], placeholder: 'item' },
|
||
{ label: '1.', title: 'Numbered list', wrap: ['1. ', ''], placeholder: 'item' },
|
||
{ label: '\uD83D\uDD17', title: 'Link', wrap: ['[', '](url)'], placeholder: 'link text' },
|
||
{ label: '\u229E', title: 'Table', insert: '| Col 1 | Col 2 | Col 3 |\n| --- | --- | --- |\n| | | |\n' }
|
||
];
|
||
|
||
function onButtonClick(event) {
|
||
var btn = event.currentTarget;
|
||
var idx = parseInt(btn.getAttribute('data-idx'), 10);
|
||
var def = buttons[idx];
|
||
if (!def) { return; }
|
||
event.preventDefault();
|
||
if (def.wrap) {
|
||
insertText(def.wrap[0], def.wrap[1], def.placeholder);
|
||
} else if (def.insert) {
|
||
insertText(def.insert, '', '');
|
||
}
|
||
syncToHiddenTextarea();
|
||
app.markDirty();
|
||
}
|
||
|
||
function createToolbar() {
|
||
if (toolbarEl) { return toolbarEl; }
|
||
toolbarEl = document.createElement('div');
|
||
toolbarEl.className = 'md-toolbar';
|
||
buttons.forEach(function (def, i) {
|
||
var btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'md-toolbar-btn';
|
||
btn.textContent = def.label;
|
||
btn.title = def.title;
|
||
btn.setAttribute('data-idx', i);
|
||
btn.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
||
btn.addEventListener('click', onButtonClick);
|
||
toolbarEl.appendChild(btn);
|
||
});
|
||
return toolbarEl;
|
||
}
|
||
|
||
// ── Visibility helpers ────────────────────────────────────────────
|
||
|
||
function refreshRender() {
|
||
var textarea = dom.qs('#remarks');
|
||
var renderEl = dom.qs('#remarks-render');
|
||
if (!textarea || !renderEl) { return; }
|
||
var value = textarea.value || '';
|
||
if (value.trim()) {
|
||
renderEl.innerHTML = markdown.render(value);
|
||
} else if (app.state.mode === 'edit' && !app.state.published) {
|
||
renderEl.innerHTML = '<span class="remarks-placeholder">Click to add remarks</span>';
|
||
} else {
|
||
renderEl.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
function showRendered() {
|
||
if (wrapperEl) { wrapperEl.style.display = 'none'; }
|
||
var renderContainer = dom.qs('#remarks-render-container');
|
||
if (renderContainer) { renderContainer.hidden = false; }
|
||
syncToHiddenTextarea();
|
||
refreshRender();
|
||
}
|
||
|
||
function showEditor() {
|
||
var renderContainer = dom.qs('#remarks-render-container');
|
||
if (renderContainer) { renderContainer.hidden = true; }
|
||
if (!initialized) { editor.init(); }
|
||
if (wrapperEl) { wrapperEl.style.display = ''; }
|
||
var remarks = dom.qs('#remarks');
|
||
if (remarks && inputEl) {
|
||
inputEl.value = remarks.value || '';
|
||
}
|
||
if (inputEl) { inputEl.focus(); }
|
||
}
|
||
|
||
// ── Click-to-edit on rendered preview ─────────────────────────────
|
||
|
||
function onRenderClick() {
|
||
if (app.state.mode !== 'edit' || app.state.published) { return; }
|
||
showEditor();
|
||
}
|
||
|
||
function bindRenderClick() {
|
||
if (renderClickBound) { return; }
|
||
var renderContainer = dom.qs('#remarks-render-container');
|
||
if (!renderContainer) { return; }
|
||
renderContainer.addEventListener('click', onRenderClick);
|
||
renderClickBound = true;
|
||
}
|
||
|
||
function setRenderClickable(clickable) {
|
||
var renderContainer = dom.qs('#remarks-render-container');
|
||
if (!renderContainer) { return; }
|
||
if (clickable) {
|
||
renderContainer.classList.add('remarks-clickable');
|
||
} else {
|
||
renderContainer.classList.remove('remarks-clickable');
|
||
}
|
||
}
|
||
|
||
// ── Outside-click collapse ────────────────────────────────────────
|
||
|
||
function onDocumentMousedown(event) {
|
||
if (!wrapperEl) { return; }
|
||
if (wrapperEl.style.display === 'none') { return; }
|
||
if (wrapperEl.contains(event.target)) { return; }
|
||
showRendered();
|
||
}
|
||
|
||
function bindOutsideClick() {
|
||
if (outsideClickBound) { return; }
|
||
document.addEventListener('mousedown', onDocumentMousedown, true);
|
||
outsideClickBound = true;
|
||
}
|
||
|
||
// ── Public API ────────────────────────────────────────────────────
|
||
|
||
editor.init = function initEditor() {
|
||
if (initialized) { return; }
|
||
var remarksWrapper = dom.qs('#remarks-wrapper');
|
||
if (!remarksWrapper) { return; }
|
||
|
||
wrapperEl = document.createElement('div');
|
||
wrapperEl.id = 'remarks-editor';
|
||
wrapperEl.style.display = 'none';
|
||
|
||
wrapperEl.appendChild(createToolbar());
|
||
|
||
// Edit area container
|
||
var editArea = document.createElement('div');
|
||
editArea.className = 'md-edit-area';
|
||
|
||
// Plain textarea
|
||
inputEl = document.createElement('textarea');
|
||
inputEl.className = 'md-input';
|
||
inputEl.spellcheck = true;
|
||
inputEl.setAttribute('aria-label', 'Remarks');
|
||
editArea.appendChild(inputEl);
|
||
|
||
wrapperEl.appendChild(editArea);
|
||
remarksWrapper.appendChild(wrapperEl);
|
||
|
||
inputEl.addEventListener('input', function () {
|
||
syncToHiddenTextarea();
|
||
app.markDirty();
|
||
});
|
||
|
||
inputEl.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
document.execCommand('insertText', false, ' ');
|
||
}
|
||
});
|
||
|
||
bindOutsideClick();
|
||
initialized = true;
|
||
};
|
||
|
||
editor.destroy = function destroyEditor() {
|
||
if (wrapperEl) { wrapperEl.style.display = 'none'; }
|
||
syncToHiddenTextarea();
|
||
};
|
||
|
||
editor.showRendered = showRendered;
|
||
editor.showEditor = showEditor;
|
||
editor.refreshRender = refreshRender;
|
||
editor.bindRenderClick = bindRenderClick;
|
||
editor.setRenderClickable = setRenderClickable;
|
||
|
||
editor.isActive = function isActive() {
|
||
return initialized && wrapperEl && wrapperEl.style.display !== 'none';
|
||
};
|
||
|
||
editor.getValue = function getValue() {
|
||
return inputEl ? inputEl.value : '';
|
||
};
|
||
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
var dom = app.dom;
|
||
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
|
||
function escapeHtml(str) {
|
||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
function escapeAttr(str) {
|
||
return str.replace(/&/g, '&').replace(/"/g, '"');
|
||
}
|
||
|
||
// Split on semicolons/commas but respect quoted strings
|
||
function splitRecipients(raw) {
|
||
var parts = [];
|
||
var current = '';
|
||
var inQuote = false;
|
||
for (var i = 0; i < raw.length; i++) {
|
||
var ch = raw[i];
|
||
if (ch === '"') {
|
||
inQuote = !inQuote;
|
||
current += ch;
|
||
} else if ((ch === ';' || ch === ',') && !inQuote) {
|
||
var trimmed = current.trim();
|
||
if (trimmed) { parts.push(trimmed); }
|
||
current = '';
|
||
} else {
|
||
current += ch;
|
||
}
|
||
}
|
||
var last = current.trim();
|
||
if (last) { parts.push(last); }
|
||
return parts;
|
||
}
|
||
|
||
// Extract email from entries like "Display Name <email@host.com>" or bare email
|
||
function extractEmail(entry) {
|
||
var angleMatch = entry.match(/<([^>]+)>/);
|
||
if (angleMatch && EMAIL_RE.test(angleMatch[1])) {
|
||
return angleMatch[1];
|
||
}
|
||
if (EMAIL_RE.test(entry)) {
|
||
return entry;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function renderRecipient(entry) {
|
||
var email = extractEmail(entry);
|
||
if (email) {
|
||
return '<a href="mailto:' + escapeAttr(email) + '" class="to-mailto">' + escapeHtml(entry) + '</a>';
|
||
}
|
||
return '<span>' + escapeHtml(entry) + '</span>';
|
||
}
|
||
|
||
function renderToField() {
|
||
var input = dom.qs('#to');
|
||
var render = dom.qs('#to-render');
|
||
if (!input || !render) { return; }
|
||
|
||
var raw = input.value || '';
|
||
var parts = splitRecipients(raw);
|
||
if (!parts.length) {
|
||
render.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
var html = parts.map(renderRecipient).join('<span class="to-sep">; </span>');
|
||
render.innerHTML = html;
|
||
}
|
||
|
||
function renderFromField() {
|
||
var input = dom.qs('#from');
|
||
var render = dom.qs('#from-render');
|
||
if (!input || !render) { return; }
|
||
|
||
var raw = (input.value || '').trim();
|
||
if (!raw) {
|
||
render.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
var email = extractEmail(raw);
|
||
if (email) {
|
||
render.innerHTML = '<a href="mailto:' + escapeAttr(email) + '" class="from-mailto">' + escapeHtml(raw) + '</a>';
|
||
} else {
|
||
render.innerHTML = '<span>' + escapeHtml(raw) + '</span>';
|
||
}
|
||
}
|
||
|
||
function init() {
|
||
var toInput = dom.qs('#to');
|
||
if (toInput) {
|
||
toInput.addEventListener('input', renderToField);
|
||
toInput.addEventListener('change', renderToField);
|
||
renderToField();
|
||
}
|
||
var fromInput = dom.qs('#from');
|
||
if (fromInput) {
|
||
fromInput.addEventListener('input', renderFromField);
|
||
fromInput.addEventListener('change', renderFromField);
|
||
renderFromField();
|
||
}
|
||
}
|
||
|
||
app.modules.emailTags = {
|
||
render: renderToField,
|
||
renderFrom: renderFromField
|
||
};
|
||
|
||
app.registerInit(init);
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const dom = app.dom;
|
||
|
||
const validation = app.modules.validation = {};
|
||
|
||
validation.bindTrackingNumberValidation = function bindTrackingNumberValidation() {
|
||
const input = dom.qs('#tracking-number');
|
||
if (!input) {
|
||
return;
|
||
}
|
||
function isInvalid(value) {
|
||
return /\s/.test(value) || /_/.test(value);
|
||
}
|
||
function apply() {
|
||
const value = input.value || '';
|
||
const bad = isInvalid(value);
|
||
input.setAttribute('aria-invalid', bad ? 'true' : 'false');
|
||
if (bad) {
|
||
input.classList.add('ring-1', 'ring-red-400', 'border-red-500');
|
||
} else {
|
||
input.classList.remove('ring-1', 'ring-red-400', 'border-red-500');
|
||
}
|
||
}
|
||
input.addEventListener('input', apply);
|
||
input.addEventListener('blur', apply);
|
||
apply();
|
||
};
|
||
|
||
validation.validateBeforePublish = function validateBeforePublish() {
|
||
const errors = [];
|
||
let focusEl = null;
|
||
const trackingInput = dom.qs('#tracking-number');
|
||
if (trackingInput) {
|
||
const value = trackingInput.value || '';
|
||
if (/\s/.test(value) || /_/.test(value)) {
|
||
errors.push('Tracking Number must not contain spaces or underscores.');
|
||
if (!focusEl) {
|
||
focusEl = trackingInput;
|
||
}
|
||
}
|
||
}
|
||
(app.data.files || []).forEach(function (file, index) {
|
||
if (file.trackingNumber && (/\s/.test(file.trackingNumber) || /_/.test(file.trackingNumber))) {
|
||
errors.push('Row ' + (index + 1) + ': File tracking number must not contain spaces or underscores.');
|
||
}
|
||
if (file.revision && /\s/.test(file.revision)) {
|
||
errors.push('Row ' + (index + 1) + ': Revision must not contain spaces.');
|
||
}
|
||
});
|
||
return {
|
||
ok: errors.length === 0,
|
||
focusEl: focusEl,
|
||
message: errors.join('\n')
|
||
};
|
||
};
|
||
|
||
app.registerInit(function () {
|
||
validation.bindTrackingNumberValidation();
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const dom = app.dom;
|
||
const util = app.util;
|
||
const json = app.json;
|
||
|
||
const security = app.modules.security = {};
|
||
|
||
// ── Verify-card DOM helpers ────────────────────────
|
||
function makeCard(variant) {
|
||
var el = document.createElement('div');
|
||
el.className = 'verify-card verify-card--' + variant;
|
||
return el;
|
||
}
|
||
function makeStatus(text, variant) {
|
||
var el = document.createElement('div');
|
||
el.className = 'verify-card__status verify-card__status--' + variant;
|
||
el.textContent = text;
|
||
return el;
|
||
}
|
||
function makeDetail(html) {
|
||
var el = document.createElement('div');
|
||
el.className = 'verify-card__detail';
|
||
el.innerHTML = html;
|
||
return el;
|
||
}
|
||
|
||
security.renderSignaturesList = async function renderSignaturesList() {
|
||
var digestEl = dom.qs('#digest-display');
|
||
var container = dom.qs('#signatures-list');
|
||
var addButton = dom.qs('#add-signature-btn');
|
||
if (!container) { return; }
|
||
|
||
var data = json.parse();
|
||
var envelope = data.envelope || {};
|
||
var signatures = Array.isArray(envelope.signatures) ? envelope.signatures : [];
|
||
var hasDigest = !!(envelope.digest);
|
||
var dv = app.data.digestVerified;
|
||
var results = app.data.signatureVerificationResults || [];
|
||
|
||
// ── Digest card ──
|
||
if (digestEl) {
|
||
digestEl.innerHTML = '';
|
||
if (hasDigest && dv) {
|
||
var unavailable = dv.unavailable;
|
||
var cardType = unavailable ? 'info' : (dv.match ? 'ok' : 'fail');
|
||
var dCard = makeCard(cardType);
|
||
if (unavailable) {
|
||
dCard.appendChild(makeStatus('Digest Present \u2014 verification unavailable', 'info'));
|
||
} else {
|
||
dCard.appendChild(makeStatus(
|
||
(dv.match ? '\u2713 Digest Verified' : '\u2717 Digest Failed'),
|
||
dv.match ? 'ok' : 'fail'
|
||
));
|
||
}
|
||
dCard.appendChild(makeDetail('SHA-256: <code>' + envelope.digest + '</code>'));
|
||
if (envelope.digestedAt) {
|
||
dCard.appendChild(makeDetail(util.formatISOWithTZ(envelope.digestedAt)));
|
||
}
|
||
if (unavailable) {
|
||
dCard.appendChild(makeDetail('<em>Open from file:// or https:// to verify integrity</em>'));
|
||
}
|
||
digestEl.appendChild(dCard);
|
||
}
|
||
}
|
||
|
||
// ── Signature cards ──
|
||
container.innerHTML = '';
|
||
if (!hasDigest) {
|
||
if (addButton) { addButton.hidden = true; }
|
||
return;
|
||
}
|
||
|
||
if (signatures.length === 0) {
|
||
var note = makeCard('info');
|
||
note.appendChild(makeStatus('No signatures \u2014 digest only', 'info'));
|
||
container.appendChild(note);
|
||
}
|
||
|
||
var cryptoUnavailable = dv && dv.unavailable;
|
||
for (var i = 0; i < signatures.length; i++) {
|
||
var sig = signatures[i];
|
||
var fingerprint = await util.publicKeyFingerprint(sig.publicKeyJwk);
|
||
var signedAt = sig.signedAt ? util.formatISOWithTZ(sig.signedAt) : 'Unknown';
|
||
var result = results.find(function (r) { return r.index === i; });
|
||
var ok = result ? result.valid : false;
|
||
var digestBad = dv && !dv.match && !cryptoUnavailable;
|
||
var pass = ok && !digestBad;
|
||
|
||
var sigLabel = sig.label || ('Signature ' + (i + 1));
|
||
var cardType = cryptoUnavailable ? 'info' : (pass ? 'ok' : 'fail');
|
||
var sCard = makeCard(cardType);
|
||
if (cryptoUnavailable) {
|
||
sCard.appendChild(makeStatus(sigLabel + ' \u2014 verification unavailable', 'info'));
|
||
} else {
|
||
sCard.appendChild(makeStatus(
|
||
(pass ? '\u2713 ' + sigLabel + ' Verified' : '\u2717 ' + sigLabel + ' Failed Verification'),
|
||
pass ? 'ok' : 'fail'
|
||
));
|
||
}
|
||
sCard.appendChild(makeDetail('Key: <code>' + (fingerprint || 'Unknown') + '</code>'));
|
||
sCard.appendChild(makeDetail(signedAt));
|
||
container.appendChild(sCard);
|
||
}
|
||
|
||
if (addButton) { addButton.hidden = false; }
|
||
};
|
||
|
||
security.deleteSignature = async function deleteSignature(index) {
|
||
if (!confirm('Delete this signature?')) { return; }
|
||
try {
|
||
var data = json.parse();
|
||
var envelope = data.envelope || {};
|
||
var signatures = Array.isArray(envelope.signatures) ? envelope.signatures : [];
|
||
signatures.splice(index, 1);
|
||
envelope.signatures = signatures;
|
||
json.setData({ envelope: envelope, payload: data.payload, presentation: data.presentation });
|
||
await security.verifySignatureIfPresent();
|
||
if (app.modules.data && app.modules.data.setStatus) {
|
||
app.modules.data.setStatus('Signature deleted', 'success');
|
||
}
|
||
} catch (err) {
|
||
console.error('[transmittal] failed to delete signature', err);
|
||
alert('Failed to delete signature: ' + (err && err.message ? err.message : err));
|
||
}
|
||
};
|
||
|
||
security.addSignature = async function addSignature(options) {
|
||
var opts = options || {};
|
||
var label = opts.label || '';
|
||
try {
|
||
var data = json.parse();
|
||
var envelope = data.envelope || {};
|
||
var payload = data.payload || {};
|
||
|
||
if (!envelope.digest) {
|
||
alert('Cannot add signature: document must be published first (needs digest).');
|
||
return;
|
||
}
|
||
|
||
var jwk = await security.pickSigningKey();
|
||
if (!jwk || jwk.kty !== 'EC') { return; }
|
||
|
||
var sigResult = await util.signEnvelope(envelope, jwk);
|
||
var publicJwk = security.derivePublicFromPrivate(jwk);
|
||
|
||
var sigEntry = {
|
||
signature: sigResult.signature,
|
||
signedAt: sigResult.signedAt,
|
||
publicKeyJwk: publicJwk
|
||
};
|
||
if (label) { sigEntry.label = label; }
|
||
|
||
var signatures = Array.isArray(envelope.signatures) ? envelope.signatures : [];
|
||
signatures.push(sigEntry);
|
||
|
||
envelope.signatures = signatures;
|
||
json.setData({ envelope: envelope, payload: payload, presentation: data.presentation });
|
||
await security.verifySignatureIfPresent();
|
||
|
||
// Persist by downloading updated HTML
|
||
var publish = app.modules.publish;
|
||
if (publish && publish.buildHtmlString) {
|
||
var html = await publish.buildHtmlString();
|
||
var dataModule = app.modules.data;
|
||
var filename = dataModule && dataModule.buildFileName
|
||
? dataModule.buildFileName(payload, { extension: 'html' })
|
||
: 'transmittal.html';
|
||
util.downloadBlob(filename, new Blob([html], { type: 'text/html' }), 'text/html');
|
||
}
|
||
|
||
var statusMsg = (label ? label : 'Signature') + ' added \u2014 download triggered';
|
||
if (app.modules.data && app.modules.data.setStatus) {
|
||
app.modules.data.setStatus(statusMsg, 'success');
|
||
}
|
||
} catch (err) {
|
||
console.error('[transmittal] failed to add signature', err);
|
||
alert('Failed to add signature: ' + (err && err.message ? err.message : err));
|
||
}
|
||
};
|
||
|
||
security.verifyPayloadSignature = async function verifyPayloadSignature(payloadStr, signatureB64, publicJwk) {
|
||
if (!signatureB64 || !publicJwk) {
|
||
return false;
|
||
}
|
||
try {
|
||
const key = await window.crypto.subtle.importKey(
|
||
'jwk',
|
||
publicJwk,
|
||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||
false,
|
||
['verify']
|
||
);
|
||
const signatureBuffer = util.base64ToArrayBuffer(signatureB64);
|
||
const ok = await window.crypto.subtle.verify(
|
||
{ name: 'ECDSA', hash: { name: 'SHA-256' } },
|
||
key,
|
||
signatureBuffer,
|
||
new TextEncoder().encode(payloadStr)
|
||
);
|
||
return !!ok;
|
||
} catch (err) {
|
||
console.warn('[transmittal] verify error', err);
|
||
return false;
|
||
}
|
||
};
|
||
|
||
security.verifySignatureIfPresent = async function verifySignatureIfPresent() {
|
||
try {
|
||
var data = json.parse();
|
||
var envelope = data.envelope || {};
|
||
var payload = data.payload || {};
|
||
var signatures = Array.isArray(envelope.signatures) ? envelope.signatures : [];
|
||
var hasDigest = !!(envelope.digest);
|
||
|
||
if (!hasDigest) {
|
||
app.data.digestVerified = null;
|
||
app.data.signatureVerificationResults = [];
|
||
await security.renderSignaturesList();
|
||
return;
|
||
}
|
||
|
||
if (!util.hasCrypto()) {
|
||
app.data.digestVerified = { match: false, unavailable: true };
|
||
app.data.signatureVerificationResults = [];
|
||
await security.renderSignaturesList();
|
||
return;
|
||
}
|
||
|
||
// Verify digest matches payload
|
||
var payloadStr = util.canonicalStringify(payload);
|
||
var computedDigest = await util.hashString(payloadStr);
|
||
var digestMatch = envelope.digest && computedDigest &&
|
||
(String(envelope.digest).toLowerCase() === computedDigest.toLowerCase());
|
||
|
||
app.data.digestVerified = {
|
||
match: digestMatch,
|
||
expected: envelope.digest,
|
||
computed: computedDigest
|
||
};
|
||
|
||
if (!digestMatch || signatures.length === 0) {
|
||
app.data.signatureVerificationResults = [];
|
||
await security.renderSignaturesList();
|
||
return;
|
||
}
|
||
|
||
// Reconstruct the envelope that was signed (without signatures)
|
||
var envelopeToVerify = {
|
||
version: envelope.version || 1,
|
||
digestAlgorithm: envelope.digestAlgorithm || app.constants.digestAlgorithm,
|
||
digest: envelope.digest,
|
||
digestedAt: envelope.digestedAt,
|
||
signatureAlgorithm: envelope.signatureAlgorithm || app.constants.signatureAlgorithm
|
||
};
|
||
var envelopeStr = util.canonicalStringify(envelopeToVerify);
|
||
|
||
var results = [];
|
||
for (var i = 0; i < signatures.length; i++) {
|
||
var sig = signatures[i];
|
||
var signatureOk = await security.verifyPayloadSignature(envelopeStr, sig.signature, sig.publicKeyJwk);
|
||
results.push({
|
||
index: i,
|
||
valid: signatureOk,
|
||
publicKey: sig.publicKeyJwk,
|
||
signedAt: sig.signedAt
|
||
});
|
||
}
|
||
|
||
app.data.signatureVerificationResults = results;
|
||
await security.renderSignaturesList();
|
||
} catch (err) {
|
||
console.error('[transmittal] signature verification failed', err);
|
||
app.data.digestVerified = { match: false, error: true };
|
||
app.data.signatureVerificationResults = [];
|
||
await security.renderSignaturesList();
|
||
}
|
||
};
|
||
|
||
security.derivePublicFromPrivate = function derivePublicFromPrivate(jwk) {
|
||
if (!jwk) {
|
||
return null;
|
||
}
|
||
if (jwk.x && jwk.y) {
|
||
return {
|
||
kty: jwk.kty || 'EC',
|
||
crv: jwk.crv || 'P-256',
|
||
x: jwk.x,
|
||
y: jwk.y,
|
||
ext: true
|
||
};
|
||
}
|
||
return null;
|
||
};
|
||
|
||
security.pickSigningKey = function pickSigningKey() {
|
||
return new Promise(function (resolve, reject) {
|
||
var input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = '.zddc-key';
|
||
input.addEventListener('change', async function () {
|
||
try {
|
||
var file = input.files && input.files[0];
|
||
if (!file) { resolve(null); return; }
|
||
var text = await file.text();
|
||
var jwk = await util.loadKeyFile(text, security.promptForPassword);
|
||
resolve(jwk);
|
||
} catch (err) {
|
||
reject(err);
|
||
}
|
||
});
|
||
input.click();
|
||
});
|
||
};
|
||
|
||
security.promptForPassword = function promptForPassword(fingerprint) {
|
||
return new Promise(function (resolve) {
|
||
var pw = window.prompt(
|
||
fingerprint
|
||
? 'Enter password for key ' + fingerprint + ':'
|
||
: 'Enter key password:'
|
||
);
|
||
resolve(pw);
|
||
});
|
||
};
|
||
|
||
app.registerInit(function () {
|
||
const addSigButton = dom.qs('#add-signature-btn');
|
||
if (addSigButton) {
|
||
addSigButton.addEventListener('click', function() {
|
||
security.addSignature();
|
||
});
|
||
}
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
var dom = app.dom;
|
||
var util = app.util;
|
||
|
||
var verification = app.modules.verification = {};
|
||
|
||
// ── Directory scanning for file hash verification ────
|
||
verification.scanDirectoryForFiles = async function scanDirectoryForFiles(dirHandle, basePath) {
|
||
var base = basePath || '';
|
||
var files = [];
|
||
|
||
for await (var entry of dirHandle.values()) {
|
||
if (entry.kind === 'file') {
|
||
var file = await entry.getFile();
|
||
var sha256 = await util.hashFile(file);
|
||
var path = base ? base + '/' + entry.name : entry.name;
|
||
files.push({ name: entry.name, path: path, sha256: sha256, size: file.size });
|
||
} else if (entry.kind === 'directory') {
|
||
var subPath = base ? base + '/' + entry.name : entry.name;
|
||
var subFiles = await verification.scanDirectoryForFiles(entry, subPath);
|
||
for (var j = 0; j < subFiles.length; j++) { files.push(subFiles[j]); }
|
||
}
|
||
}
|
||
|
||
return files;
|
||
};
|
||
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const dom = app.dom;
|
||
const json = app.json;
|
||
|
||
const dataModule = app.modules.data = {};
|
||
|
||
const STATUS_DISPLAY_MS = 6000;
|
||
let statusTimer = null;
|
||
|
||
function qs(selector) {
|
||
return dom.qs(selector);
|
||
}
|
||
|
||
function setStatus(message, type) {
|
||
const element = qs('#data-status');
|
||
if (!element) {
|
||
return;
|
||
}
|
||
element.textContent = message || '';
|
||
element.dataset.statusType = type || '';
|
||
element.classList.remove('text-red-600', 'text-green-600');
|
||
if (type === 'success') {
|
||
element.classList.add('text-green-600');
|
||
} else if (type === 'error') {
|
||
element.classList.add('text-red-600');
|
||
}
|
||
if (statusTimer) {
|
||
clearTimeout(statusTimer);
|
||
}
|
||
if (message) {
|
||
statusTimer = setTimeout(function () {
|
||
element.textContent = '';
|
||
element.classList.remove('text-red-600', 'text-green-600');
|
||
}, STATUS_DISPLAY_MS);
|
||
}
|
||
}
|
||
|
||
const INVALID_FILENAME_CHARS = /[\\/:*?"<>|]+/g;
|
||
|
||
function sanitizeFilenameSegment(value, fallback) {
|
||
const trimmed = (value || '').toString().trim();
|
||
if (!trimmed) {
|
||
return fallback || '';
|
||
}
|
||
return trimmed
|
||
.replace(INVALID_FILENAME_CHARS, '-')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function sanitizeTitle(value) {
|
||
const sanitized = sanitizeFilenameSegment(value, 'Transmittal');
|
||
return sanitized || 'Transmittal';
|
||
}
|
||
|
||
function sanitizeTracking(value) {
|
||
const sanitized = sanitizeFilenameSegment(value, 'TRANS');
|
||
return sanitized.replace(/[_\s]+/g, '-');
|
||
}
|
||
|
||
function sanitizeStatus(value, fallback) {
|
||
const sanitized = sanitizeFilenameSegment(value, fallback || '');
|
||
const cleaned = sanitized.replace(/[()]/g, '').trim();
|
||
if (cleaned) {
|
||
return cleaned;
|
||
}
|
||
return (fallback || 'Unspecified');
|
||
}
|
||
|
||
function sanitizeDate(value) {
|
||
const trimmed = (value || '').toString().trim();
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||
return trimmed;
|
||
}
|
||
if (!trimmed) {
|
||
return '0000-00-00';
|
||
}
|
||
return trimmed.replace(/\s+/g, '-');
|
||
}
|
||
|
||
// Folder name: DATE_TRACKING (STATUS) - Title (date first for chronological sort)
|
||
dataModule.buildFolderName = function buildFolderName(payload) {
|
||
const p = payload || {};
|
||
const date = sanitizeDate(p.date || '');
|
||
const tracking = sanitizeTracking(p.trackingNumber || '');
|
||
const status = sanitizeStatus(p.status || p.purpose || '', 'Unspecified');
|
||
const title = sanitizeTitle(p.subject || p.title || '');
|
||
const statusPart = status ? ' (' + status + ')' : '';
|
||
return date + '_' + tracking + statusPart + ' - ' + title;
|
||
};
|
||
|
||
// File name: TRACKING_[~]DATE (STATUS) - Subject.ext
|
||
// draft: true → ~DATE (tilde prefix indicates draft)
|
||
dataModule.buildFileName = function buildFileName(payload, options) {
|
||
const p = payload || {};
|
||
const opts = options || {};
|
||
const ext = opts.extension ? opts.extension.replace(/^\.+/, '') : 'json';
|
||
const isDraft = !!opts.draft;
|
||
const tracking = sanitizeTracking(p.trackingNumber || '');
|
||
const date = sanitizeDate(p.date || '');
|
||
const status = sanitizeStatus(p.status || p.purpose || '', 'Unspecified');
|
||
const title = sanitizeTitle(p.subject || p.title || '');
|
||
const statusPart = status ? ' (' + status + ')' : '';
|
||
const datePrefix = isDraft ? '~' : '';
|
||
return tracking + '_' + datePrefix + date + statusPart + ' - ' + title + '.' + ext;
|
||
};
|
||
|
||
async function saveFileWithPicker(filename, contents, mime) {
|
||
if (typeof window.showSaveFilePicker !== 'function') {
|
||
app.util.downloadBlob(filename, contents, mime);
|
||
return filename;
|
||
}
|
||
const handle = await window.showSaveFilePicker({
|
||
suggestedName: filename,
|
||
types: [
|
||
{
|
||
description: mime || 'File',
|
||
accept: { [mime || 'application/octet-stream']: ['.' + zddc.splitExtension(filename).extension] }
|
||
}
|
||
]
|
||
});
|
||
const writable = await handle.createWritable();
|
||
await writable.write(new Blob([contents], { type: mime || 'application/octet-stream' }));
|
||
await writable.close();
|
||
return handle.name || filename;
|
||
}
|
||
|
||
async function serializeUiToJson() {
|
||
if (!app.modules.publish || typeof app.modules.publish.syncUiToJson !== 'function') {
|
||
throw new Error('Publish module not ready');
|
||
}
|
||
await app.modules.publish.syncUiToJson({ sign: false, computeDigest: false });
|
||
app.state.dirty = false;
|
||
}
|
||
|
||
// ── Extract JSON from an HTML transmittal string ────
|
||
function extractJsonFromHtml(htmlText) {
|
||
var m = htmlText.match(new RegExp('<script\\s+id\\s*=\\s*["\']transmittal-data["\'][^>]*>([\\s\\S]*?)</' + 'script>', 'i'));
|
||
if (!m || !m[1]) { return null; }
|
||
try {
|
||
var data = JSON.parse(m[1]);
|
||
if (data && typeof data === 'object' && data.payload) { return data; }
|
||
} catch (_) { /* not valid JSON */ }
|
||
return null;
|
||
}
|
||
|
||
function pickHtmlFile() {
|
||
if (typeof window.showOpenFilePicker === 'function') {
|
||
return window.showOpenFilePicker({
|
||
multiple: false,
|
||
types: [{ description: 'HTML Files', accept: { 'text/html': ['.html', '.htm'] } }]
|
||
}).then(function (handles) {
|
||
var handle = handles[0];
|
||
return handle.getFile().then(function (file) {
|
||
return { file: file, name: handle.name || file.name || 'import.html' };
|
||
});
|
||
});
|
||
}
|
||
return new Promise(function (resolve, reject) {
|
||
var input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'text/html,.html,.htm';
|
||
input.addEventListener('change', function () {
|
||
var file = input.files && input.files[0];
|
||
if (!file) { reject(new Error('No file selected')); return; }
|
||
resolve({ file: file, name: file.name || 'import.html' });
|
||
});
|
||
input.click();
|
||
});
|
||
}
|
||
|
||
async function handleImportHtml() {
|
||
try {
|
||
var picked = await pickHtmlFile();
|
||
var text = await picked.file.text();
|
||
var data = extractJsonFromHtml(text);
|
||
if (!data) { throw new Error('No valid transmittal data found in this HTML file'); }
|
||
await applyLoadedData(data, picked.name || 'import.html');
|
||
} catch (err) {
|
||
if (err && (err.name === 'AbortError' || err.name === 'NotAllowedError')) { return; }
|
||
console.error('[transmittal] import-html failed', err);
|
||
setStatus('Failed to import HTML: ' + (err && err.message ? err.message : err), 'error');
|
||
}
|
||
}
|
||
|
||
async function applyLoadedData(data, sourceName) {
|
||
json.setData(data);
|
||
app.modules.files.loadFromJson();
|
||
app.state.mode = 'edit';
|
||
app.state.dirty = false;
|
||
if (app.modules.visibility && app.modules.visibility.applyFieldVisibility) {
|
||
app.modules.visibility.applyFieldVisibility();
|
||
}
|
||
await app.modules.security.verifySignatureIfPresent();
|
||
if (app.modules.security.renderSignaturesList) {
|
||
await app.modules.security.renderSignaturesList();
|
||
}
|
||
app.modules.mode.refresh();
|
||
app.state.apply();
|
||
if (app.modules.liveDigest && app.modules.liveDigest.schedule) {
|
||
app.modules.liveDigest.schedule();
|
||
}
|
||
setStatus('Loaded: ' + sourceName, 'success');
|
||
}
|
||
|
||
app.onDirty(function () {
|
||
setStatus('Unsaved changes', 'info');
|
||
});
|
||
|
||
async function handleLoadFromClipboard() {
|
||
try {
|
||
var text = await navigator.clipboard.readText();
|
||
if (!text || !text.trim()) {
|
||
setStatus('Clipboard is empty', 'error');
|
||
return;
|
||
}
|
||
var data = JSON.parse(text.trim());
|
||
if (!data || typeof data !== 'object' || !data.payload) {
|
||
throw new Error('Invalid ZDDC JSON data');
|
||
}
|
||
await applyLoadedData(data, 'clipboard');
|
||
} catch (err) {
|
||
console.error('[transmittal] load-from-clipboard failed', err);
|
||
if (err && err.name === 'NotAllowedError') {
|
||
setStatus('Clipboard access denied — please paste manually', 'error');
|
||
} else {
|
||
setStatus('Failed to load from clipboard: ' + (err && err.message ? err.message : err), 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleCopyJson() {
|
||
try {
|
||
await serializeUiToJson();
|
||
var data = json.parse();
|
||
var text = JSON.stringify(data, null, 2);
|
||
await navigator.clipboard.writeText(text);
|
||
setStatus('JSON copied to clipboard', 'success');
|
||
} catch (err) {
|
||
console.error('[transmittal] copy-json failed', err);
|
||
if (err && err.name === 'NotAllowedError') {
|
||
setStatus('Clipboard access denied', 'error');
|
||
} else {
|
||
setStatus('Failed to copy JSON: ' + (err && err.message ? err.message : err), 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleSaveHtmlDraft() {
|
||
try {
|
||
var pub = app.modules.publish;
|
||
if (!pub || typeof pub.syncUiToJson !== 'function' || typeof pub.buildHtmlString !== 'function') {
|
||
throw new Error('Publish module not ready');
|
||
}
|
||
await pub.syncUiToJson({ sign: false, computeDigest: false });
|
||
var html = await pub.buildHtmlString();
|
||
var data = json.parse();
|
||
var payload = (data && data.payload) || {};
|
||
var filename = dataModule.buildFileName(payload, { extension: 'html', draft: true });
|
||
await saveFileWithPicker(filename, html, 'text/html');
|
||
setStatus('Saved draft as ' + filename, 'success');
|
||
} catch (err) {
|
||
console.error('[transmittal] save-html-draft failed', err);
|
||
setStatus('Failed to save draft: ' + (err && err.message ? err.message : err), 'error');
|
||
}
|
||
}
|
||
|
||
async function handleCopyTable() {
|
||
try {
|
||
var TAB = '\t';
|
||
var NL = '\r\n';
|
||
var esc = app.util.escapeHtml;
|
||
var textStyle = ' style="mso-number-format:\'\\@\'"';
|
||
var headers = ['#', 'TRACKING NUMBER', 'TITLE', 'REVISION', 'STATUS', 'EXT', 'SIZE', 'SHA256'];
|
||
var headerTsv = headers.join(TAB);
|
||
var headerHtml = '<tr>' + headers.map(function (h) { return '<th' + textStyle + '>' + esc(h) + '</th>'; }).join('') + '</tr>';
|
||
var tsvLines = [headerTsv];
|
||
var htmlRows = [headerHtml];
|
||
|
||
function addRow(cells) {
|
||
tsvLines.push(cells.join(TAB));
|
||
htmlRows.push('<tr>' + cells.map(function (c) { return '<td' + textStyle + '>' + esc(c) + '</td>'; }).join('') + '</tr>');
|
||
}
|
||
|
||
// Row 0: self-entry
|
||
var filesModule = app.modules.files;
|
||
var self = filesModule && filesModule.buildSelfEntry ? filesModule.buildSelfEntry() : null;
|
||
if (self) {
|
||
addRow(['0', self.trackingNumber || '', self.title || '', self.revision || '', self.status || '', 'html', '\u2014', 'see above']);
|
||
}
|
||
|
||
// Regular files
|
||
var files = Array.isArray(app.data.files) ? app.data.files : [];
|
||
for (var i = 0; i < files.length; i++) {
|
||
var f = files[i];
|
||
var size = (f.fileSize != null ? f.fileSize : f.size);
|
||
addRow([
|
||
String(i + 1),
|
||
f.trackingNumber || '',
|
||
f.title || '',
|
||
f.revision || '',
|
||
f.status || '',
|
||
(f.extension || '').toLowerCase(),
|
||
size ? String(size) : '',
|
||
f.sha256 || ''
|
||
]);
|
||
}
|
||
|
||
var plainText = tsvLines.join(NL);
|
||
var html = '<table>' + htmlRows.join('') + '</table>';
|
||
await navigator.clipboard.write([
|
||
new ClipboardItem({
|
||
'text/plain': new Blob([plainText], { type: 'text/plain' }),
|
||
'text/html': new Blob([html], { type: 'text/html' })
|
||
})
|
||
]);
|
||
setStatus('Table copied (' + files.length + ' files)', 'success');
|
||
} catch (err) {
|
||
console.error('[transmittal] copy-table failed', err);
|
||
setStatus('Failed to copy table: ' + (err && err.message ? err.message : err), 'error');
|
||
}
|
||
}
|
||
|
||
async function handleRevise() {
|
||
if (!confirm('This will create a new draft from the published transmittal. The digest and signatures will be removed. Continue?')) {
|
||
return;
|
||
}
|
||
var data = json.parse();
|
||
if (!data) { return; }
|
||
// Strip digest and signatures; preserve existing date for user to update
|
||
data.envelope = { version: 1, digestAlgorithm: app.constants.digestAlgorithm, digest: '', digestedAt: '', signatureAlgorithm: app.constants.signatureAlgorithm, signatures: [] };
|
||
json.setData(data);
|
||
app.modules.files.loadFromJson();
|
||
app.state.mode = 'edit';
|
||
app.state.published = false;
|
||
app.state.dirty = true;
|
||
app.modules.mode.refresh();
|
||
setStatus('Created new draft from published transmittal', 'success');
|
||
}
|
||
|
||
dataModule.extractJsonFromHtml = extractJsonFromHtml;
|
||
dataModule.setStatus = setStatus;
|
||
dataModule.serializeUiToJson = serializeUiToJson;
|
||
dataModule.handleLoadFromClipboard = handleLoadFromClipboard;
|
||
dataModule.handleCopyJson = handleCopyJson;
|
||
dataModule.handleCopyTable = handleCopyTable;
|
||
dataModule.handleSaveHtmlDraft = handleSaveHtmlDraft;
|
||
dataModule.handleRevise = handleRevise;
|
||
dataModule.handleImportHtml = handleImportHtml;
|
||
dataModule.applyLoadedData = applyLoadedData;
|
||
dataModule.buildFileName = dataModule.buildFileName || buildFileName;
|
||
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const dom = app.dom;
|
||
const util = app.util;
|
||
const json = app.json;
|
||
const security = app.modules.security;
|
||
const validation = app.modules.validation;
|
||
const filesModule = app.modules.files;
|
||
const dataModule = app.modules.data;
|
||
const mode = app.modules.mode;
|
||
|
||
async function syncUiToJson(options) {
|
||
const sign = !!(options && options.sign);
|
||
const computeDigest = !!(options && options.computeDigest);
|
||
|
||
// First, sync UI to JSON to ensure JSON is up-to-date
|
||
filesModule.syncUiToJson();
|
||
|
||
// Now use JSON as single source of truth
|
||
const data = json.parse();
|
||
const previousEnvelope = (data && data.envelope) || {};
|
||
const payload = (data && data.payload) || {};
|
||
const previousPresentation = (data && data.presentation) || {};
|
||
const payloadStr = util.canonicalStringify(payload);
|
||
const previousPayloadStr = util.canonicalStringify(payload);
|
||
const payloadChanged = false; // Always false since we just synced
|
||
|
||
let digest = previousEnvelope.digest || '';
|
||
let digestedAt = previousEnvelope.digestedAt || '';
|
||
let signatures = Array.isArray(previousEnvelope.signatures) ? previousEnvelope.signatures.slice() : [];
|
||
|
||
let computedDigest = '';
|
||
if (sign || computeDigest || payloadChanged || !digest) {
|
||
try {
|
||
computedDigest = await util.hashString(payloadStr);
|
||
} catch (err) {
|
||
computedDigest = '';
|
||
}
|
||
}
|
||
|
||
if (sign || computeDigest) {
|
||
if (!computedDigest) {
|
||
throw new Error('Unable to compute digest.');
|
||
}
|
||
digest = computedDigest;
|
||
digestedAt = util.fetchTrustedTime();
|
||
}
|
||
|
||
if (sign) {
|
||
const jwk = options && options.signingKey;
|
||
if (!jwk || jwk.kty !== 'EC') {
|
||
throw new Error('A valid EC private key (JWK) is required to sign this transmittal.');
|
||
}
|
||
const envelopeForSigning = {
|
||
version: 1,
|
||
digestAlgorithm: app.constants.digestAlgorithm,
|
||
digest: digest,
|
||
digestedAt: digestedAt,
|
||
signatureAlgorithm: app.constants.signatureAlgorithm
|
||
};
|
||
const sigResult = await util.signEnvelope(envelopeForSigning, jwk);
|
||
const publicJwk = security.derivePublicFromPrivate(jwk);
|
||
signatures.push({
|
||
signature: sigResult.signature,
|
||
signedAt: sigResult.signedAt,
|
||
publicKeyJwk: publicJwk
|
||
});
|
||
}
|
||
|
||
const envelope = {
|
||
version: 1,
|
||
digestAlgorithm: app.constants.digestAlgorithm,
|
||
digest: digest,
|
||
digestedAt: digestedAt,
|
||
signatureAlgorithm: app.constants.signatureAlgorithm,
|
||
signatures: signatures
|
||
};
|
||
const presentation = previousPresentation;
|
||
json.setData({ envelope: envelope, payload: payload, presentation: presentation });
|
||
if (computeDigest || sign) {
|
||
try {
|
||
await security.verifySignatureIfPresent();
|
||
} catch (_) {
|
||
// ignore
|
||
}
|
||
app.state.apply();
|
||
}
|
||
app.state.dirty = false;
|
||
}
|
||
|
||
async function buildHtmlString() {
|
||
// Hide editor before cloning so its markup is not in the output
|
||
var mdEditor = app.modules.markdownEditor;
|
||
var editorWasActive = mdEditor && mdEditor.isActive();
|
||
if (editorWasActive) { mdEditor.destroy(); }
|
||
|
||
// ── Preferred path: fetch the page's own source and patch the JSON ──
|
||
// This produces an exact copy of the original file with only the data
|
||
// changed, avoiding DOM-snapshot drift. Falls back to the DOM-snapshot
|
||
// approach when the page cannot fetch itself (e.g. opaque origins).
|
||
try {
|
||
var jsonData = app.json.parse();
|
||
var html = await util.fetchAndPatchHtml(jsonData);
|
||
if (editorWasActive && mdEditor && typeof mdEditor.showRendered === 'function') {
|
||
mdEditor.showRendered();
|
||
}
|
||
return html;
|
||
} catch (fetchErr) {
|
||
console.warn('[transmittal] fetch-based save unavailable, falling back to DOM snapshot:', fetchErr.message);
|
||
}
|
||
|
||
// ── Fallback path: DOM snapshot ─────────────────────────────────────
|
||
// Populate static content before cloning for progressive enhancement
|
||
if (app.modules.hydrate && app.modules.hydrate.populateStatic) {
|
||
await app.modules.hydrate.populateStatic();
|
||
}
|
||
|
||
// Remove any existing success notifications
|
||
var notifications = document.querySelectorAll('[data-publish-notification]');
|
||
notifications.forEach(function (notif) { notif.remove(); });
|
||
|
||
// Hide workflow edit-only steps before cloning (viewers shouldn't see them)
|
||
var workflowEditSteps = dom.qsa('.workflow-step[data-edit-only], .workflow-tools');
|
||
var wasVisible = [];
|
||
workflowEditSteps.forEach(function (el) {
|
||
wasVisible.push(!el.hidden);
|
||
el.hidden = true;
|
||
});
|
||
|
||
// Re-hide bottom menu and restore no-js notice before cloning
|
||
var bottomMenu = dom.qs('#bottom-menu');
|
||
var bottomMenuWasVisible = bottomMenu && !bottomMenu.hidden;
|
||
if (bottomMenu) { bottomMenu.hidden = true; }
|
||
var noJsNotice = dom.qs('#no-js-notice');
|
||
var addedNotice = false;
|
||
if (!noJsNotice && bottomMenu && bottomMenu.parentNode) {
|
||
noJsNotice = document.createElement('span');
|
||
noJsNotice.id = 'no-js-notice';
|
||
noJsNotice.className = 'text-gray-400 text-xs italic';
|
||
noJsNotice.textContent = 'JavaScript not available';
|
||
bottomMenu.parentNode.insertBefore(noJsNotice, bottomMenu.nextSibling);
|
||
addedNotice = true;
|
||
}
|
||
|
||
// Close any open dialogs before cloning (they get reopened after)
|
||
var openDialogs = Array.from(document.querySelectorAll('dialog[open]'));
|
||
openDialogs.forEach(function (dlg) { dlg.close(); });
|
||
|
||
var snapshotHtml = util.cloneDocumentHtml();
|
||
|
||
// Restore bottom menu visibility
|
||
if (bottomMenu && bottomMenuWasVisible) { bottomMenu.hidden = false; }
|
||
if (noJsNotice) { dom.show(noJsNotice, false); }
|
||
|
||
// Reopen dialogs that were open
|
||
openDialogs.forEach(function (dlg) { dlg.showModal(); });
|
||
|
||
// Restore workflow elements
|
||
workflowEditSteps.forEach(function (el, i) {
|
||
if (wasVisible[i]) { el.hidden = false; }
|
||
});
|
||
|
||
// Re-hydrate to restore dynamic state after cloning
|
||
if (app.modules.hydrate && app.modules.hydrate.hydrate) {
|
||
app.modules.hydrate.hydrate();
|
||
}
|
||
|
||
// Show rendered preview again (editor loads on next click)
|
||
if (mdEditor) { mdEditor.showRendered(); }
|
||
|
||
return snapshotHtml;
|
||
}
|
||
|
||
function requireDirectoryHandle() {
|
||
if (!app.data.selectedDirHandle) {
|
||
throw new Error('Select a directory before saving.');
|
||
}
|
||
return app.data.selectedDirHandle;
|
||
}
|
||
|
||
async function writeHtmlToDirectory(filename, html) {
|
||
requireDirectoryHandle();
|
||
await filesModule.writeFileToSelectedDir(filename, html, 'text/html');
|
||
}
|
||
|
||
function loadSigningKey(providedKey) {
|
||
if (providedKey) { return providedKey; }
|
||
throw new Error('A signing key is required. Select or generate one first.');
|
||
}
|
||
|
||
async function executePublish(config) {
|
||
const modeSelection = (config && config.mode) || 'digest';
|
||
const wantDownload = !!(config && config.download);
|
||
const wantSaveToFolder = !!(config && config.saveToFolder);
|
||
|
||
const validationResult = validation.validateBeforePublish();
|
||
if (!validationResult.ok) {
|
||
const error = new Error(validationResult.message || 'Validation failed before publishing.');
|
||
error.focusEl = validationResult.focusEl || null;
|
||
throw error;
|
||
}
|
||
|
||
if (wantSaveToFolder) {
|
||
requireDirectoryHandle();
|
||
}
|
||
|
||
const shouldSign = modeSelection === 'signed';
|
||
const shouldComputeDigest = modeSelection === 'signed' || modeSelection === 'digest';
|
||
const isDraft = modeSelection === 'draft';
|
||
|
||
let signingKey = null;
|
||
if (shouldSign) {
|
||
signingKey = await loadSigningKey(config && config.signingKey);
|
||
}
|
||
|
||
await syncUiToJson({
|
||
sign: shouldSign,
|
||
computeDigest: shouldComputeDigest,
|
||
signingKey: signingKey
|
||
});
|
||
|
||
mode.refresh();
|
||
const html = await buildHtmlString();
|
||
const data = json.parse();
|
||
const payload = (data && data.payload) || {};
|
||
const filename = dataModule.buildFileName(payload, { extension: 'html', draft: isDraft });
|
||
|
||
if (wantSaveToFolder) {
|
||
await writeHtmlToDirectory(filename, html);
|
||
// If publishing (not draft), delete the draft file from the directory
|
||
if (!isDraft) {
|
||
var draftName = dataModule.buildFileName(payload, { extension: 'html', draft: true });
|
||
if (draftName !== filename) {
|
||
try {
|
||
await app.data.selectedDirHandle.removeEntry(draftName);
|
||
} catch (_) {
|
||
// Draft file may not exist — that's fine
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (wantDownload) {
|
||
util.downloadBlob(filename, new Blob([html], { type: 'text/html' }), 'text/html');
|
||
}
|
||
mode.refresh();
|
||
dataModule.setStatus('Published HTML as ' + filename, 'success');
|
||
|
||
return {
|
||
filename: filename,
|
||
mode: modeSelection
|
||
};
|
||
}
|
||
|
||
app.modules.publish = {
|
||
syncUiToJson,
|
||
buildHtmlString,
|
||
executePublish
|
||
};
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
var dom = app.dom;
|
||
var json = app.json;
|
||
var filesModule = app.modules.files;
|
||
var security = app.modules.security;
|
||
|
||
function originalEmptyData() {
|
||
return {
|
||
envelope: {
|
||
version: 1,
|
||
digestAlgorithm: app.constants.digestAlgorithm,
|
||
digest: '',
|
||
digestedAt: '',
|
||
signatureAlgorithm: app.constants.signatureAlgorithm,
|
||
signatures: []
|
||
},
|
||
payload: {
|
||
version: 1,
|
||
type: 'Transmittal',
|
||
title: '',
|
||
client: '',
|
||
project: '',
|
||
projectNumber: '',
|
||
date: '',
|
||
trackingNumber: '',
|
||
from: '',
|
||
to: '',
|
||
purpose: '',
|
||
responseDue: '',
|
||
subject: '',
|
||
remarks: '',
|
||
files: []
|
||
},
|
||
presentation: {
|
||
leftLogo: '',
|
||
rightLogo: '',
|
||
theme: 'default',
|
||
customCss: ''
|
||
}
|
||
};
|
||
}
|
||
|
||
async function handleReset() {
|
||
if (!confirm('Reset will clear all data. Continue?')) {
|
||
return;
|
||
}
|
||
json.setData(originalEmptyData());
|
||
|
||
// Clear logos in DOM
|
||
var leftLogo = dom.qs('#left-logo');
|
||
var rightLogo = dom.qs('#right-logo');
|
||
if (leftLogo) { leftLogo.src = ''; }
|
||
if (rightLogo) { rightLogo.src = ''; }
|
||
|
||
// Clear runtime state
|
||
app.data.files = [];
|
||
app.data.selectedDirHandle = null;
|
||
app.data.selectedDirName = '';
|
||
|
||
filesModule.loadFromJson();
|
||
app.state.mode = 'edit';
|
||
app.state.published = false;
|
||
app.state.dirty = false;
|
||
if (security.renderSignaturesList) {
|
||
security.renderSignaturesList();
|
||
}
|
||
if (app.modules.mode && app.modules.mode.refresh) {
|
||
app.modules.mode.refresh();
|
||
}
|
||
app.state.apply();
|
||
if (app.modules.liveDigest && app.modules.liveDigest.schedule) {
|
||
app.modules.liveDigest.schedule();
|
||
}
|
||
}
|
||
|
||
app.modules.reset = {
|
||
handleReset: handleReset
|
||
};
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
if (!app || !app.dom || !app.modules) {
|
||
console.error('[publish-modal] App not properly initialized');
|
||
return;
|
||
}
|
||
|
||
var dom = app.dom;
|
||
var util = app.util;
|
||
|
||
function qs(sel) { return dom.qs(sel); }
|
||
|
||
// ── Date helpers ────────────────────────────────────
|
||
function getTodayIso() {
|
||
var d = new Date();
|
||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||
}
|
||
|
||
function toIsoDate(str) {
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) { return str; }
|
||
var d = new Date(str);
|
||
if (isNaN(d.getTime())) { return str; }
|
||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||
}
|
||
|
||
function syncDateWarning() {
|
||
var dateInput = qs('#publish-date-input');
|
||
var warning = qs('#publish-date-warning');
|
||
if (!dateInput || !warning) { return; }
|
||
var val = (dateInput.value || '').trim();
|
||
var today = getTodayIso();
|
||
if (!val || val === today) {
|
||
warning.textContent = '';
|
||
warning.hidden = true;
|
||
} else {
|
||
warning.textContent = '\u26A0 Date differs from today (' + today + ')';
|
||
warning.hidden = false;
|
||
}
|
||
}
|
||
|
||
// ── Feedback helpers ────────────────────────────────
|
||
function showFeedback(feedbackEl, message, type) {
|
||
if (!feedbackEl) { return; }
|
||
feedbackEl.textContent = message || '';
|
||
feedbackEl.style.color = type === 'error' ? '#dc2626' : type === 'success' ? '#16a34a' : '';
|
||
}
|
||
|
||
// ── Success notification ────────────────────────────
|
||
function showSuccessNotification(filename) {
|
||
var notification = document.createElement('div');
|
||
notification.className = 'publish-notification';
|
||
notification.setAttribute('data-publish-notification', 'true');
|
||
|
||
var title = document.createElement('div');
|
||
title.className = 'publish-notification__title';
|
||
title.textContent = '\u2713 Transmittal Published Successfully';
|
||
|
||
var file = document.createElement('div');
|
||
file.className = 'publish-notification__file';
|
||
file.textContent = 'Saved as: ' + filename;
|
||
|
||
var closeBtn = document.createElement('button');
|
||
closeBtn.className = 'publish-notification__close';
|
||
closeBtn.textContent = 'Close';
|
||
closeBtn.addEventListener('click', function () { notification.remove(); });
|
||
|
||
notification.appendChild(title);
|
||
notification.appendChild(file);
|
||
notification.appendChild(closeBtn);
|
||
document.body.appendChild(notification);
|
||
setTimeout(function () { if (notification.parentElement) { notification.remove(); } }, 10000);
|
||
}
|
||
|
||
// ── Password dialog ─────────────────────────────────
|
||
function promptForPassword(fingerprint) {
|
||
return new Promise(function (resolve) {
|
||
var dialog = qs('#password-dialog');
|
||
var input = qs('#password-dialog-input');
|
||
var okBtn = qs('#password-dialog-ok');
|
||
var feedback = qs('#password-dialog-feedback');
|
||
var fpEl = qs('#password-dialog-fingerprint');
|
||
if (!dialog || !input || !okBtn) { resolve(null); return; }
|
||
|
||
input.value = '';
|
||
showFeedback(feedback, '', '');
|
||
if (fpEl) {
|
||
fpEl.textContent = fingerprint ? 'Key fingerprint: ' + fingerprint : 'This key is password-protected.';
|
||
}
|
||
dialog.showModal();
|
||
input.focus();
|
||
|
||
function cleanup() {
|
||
okBtn.removeEventListener('click', onOk);
|
||
dialog.removeEventListener('close', onClose);
|
||
}
|
||
function onOk() {
|
||
var pw = input.value;
|
||
cleanup();
|
||
dialog.close();
|
||
resolve(pw);
|
||
}
|
||
function onClose() {
|
||
cleanup();
|
||
resolve(null);
|
||
}
|
||
okBtn.addEventListener('click', onOk);
|
||
dialog.addEventListener('close', onClose);
|
||
|
||
// Enter key submits
|
||
input.addEventListener('keydown', function handler(e) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
input.removeEventListener('keydown', handler);
|
||
onOk();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Key dialog ──────────────────────────────────────
|
||
function openKeyDialog() {
|
||
return new Promise(function (resolve) {
|
||
var dialog = qs('#key-dialog');
|
||
var selectBtn = qs('#key-select-file');
|
||
var generateBtn = qs('#key-generate');
|
||
var generatePanel = qs('#key-generate-panel');
|
||
var genConfirmBtn = qs('#key-generate-confirm');
|
||
var genCancelBtn = qs('#key-generate-cancel');
|
||
var pwInput = qs('#key-password');
|
||
var pwConfirm = qs('#key-password-confirm');
|
||
var feedback = qs('#key-dialog-feedback');
|
||
if (!dialog) { resolve(null); return; }
|
||
|
||
// Reset state
|
||
if (generatePanel) { generatePanel.hidden = true; }
|
||
if (pwInput) { pwInput.value = ''; }
|
||
if (pwConfirm) { pwConfirm.value = ''; }
|
||
showFeedback(feedback, '', '');
|
||
dialog.showModal();
|
||
|
||
var resolved = false;
|
||
function done(jwk) {
|
||
if (resolved) { return; }
|
||
resolved = true;
|
||
cleanup();
|
||
if (dialog.open) { dialog.close(); }
|
||
resolve(jwk);
|
||
}
|
||
function cleanup() {
|
||
if (selectBtn) { selectBtn.removeEventListener('click', onSelect); }
|
||
if (generateBtn) { generateBtn.removeEventListener('click', onGenerate); }
|
||
if (genConfirmBtn) { genConfirmBtn.removeEventListener('click', onGenConfirm); }
|
||
if (genCancelBtn) { genCancelBtn.removeEventListener('click', onGenCancel); }
|
||
dialog.removeEventListener('close', onDialogClose);
|
||
}
|
||
|
||
function onDialogClose() { done(null); }
|
||
|
||
async function onSelect() {
|
||
try {
|
||
var security = app.modules.security;
|
||
var jwk = await pickKeyFileWithPassword();
|
||
if (jwk) { done(jwk); }
|
||
} catch (err) {
|
||
showFeedback(feedback, 'Failed to load key: ' + (err.message || err), 'error');
|
||
}
|
||
}
|
||
|
||
function onGenerate() {
|
||
if (generatePanel) { generatePanel.hidden = false; }
|
||
if (generateBtn) { generateBtn.hidden = true; }
|
||
if (selectBtn) { selectBtn.hidden = true; }
|
||
}
|
||
|
||
function onGenCancel() {
|
||
if (generatePanel) { generatePanel.hidden = true; }
|
||
if (generateBtn) { generateBtn.hidden = false; }
|
||
if (selectBtn) { selectBtn.hidden = false; }
|
||
if (pwInput) { pwInput.value = ''; }
|
||
if (pwConfirm) { pwConfirm.value = ''; }
|
||
}
|
||
|
||
async function onGenConfirm() {
|
||
var pw = pwInput ? pwInput.value : '';
|
||
var pwc = pwConfirm ? pwConfirm.value : '';
|
||
if (pw && pw !== pwc) {
|
||
showFeedback(feedback, 'Passwords do not match.', 'error');
|
||
return;
|
||
}
|
||
try {
|
||
showFeedback(feedback, 'Generating key\u2026', '');
|
||
var security = app.modules.security;
|
||
var keys = await window.crypto.subtle.generateKey(
|
||
{ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']
|
||
);
|
||
var privateJwk = await window.crypto.subtle.exportKey('jwk', keys.privateKey);
|
||
var publicJwk = security.derivePublicFromPrivate(privateJwk);
|
||
var fingerprint = await util.publicKeyFingerprint(publicJwk);
|
||
|
||
var keyFileData;
|
||
if (pw) {
|
||
keyFileData = await util.encryptPrivateKey(privateJwk, pw, fingerprint);
|
||
} else {
|
||
keyFileData = util.wrapKeyFile(privateJwk, fingerprint);
|
||
}
|
||
util.downloadBlob('signing-key.zddc-key', JSON.stringify(keyFileData, null, 2), 'application/json');
|
||
showFeedback(feedback, 'Key downloaded. Fingerprint: ' + (fingerprint || '(unknown)'), 'success');
|
||
done(privateJwk);
|
||
} catch (err) {
|
||
showFeedback(feedback, 'Failed to generate key: ' + (err.message || err), 'error');
|
||
}
|
||
}
|
||
|
||
if (selectBtn) { selectBtn.addEventListener('click', onSelect); }
|
||
if (generateBtn) { generateBtn.addEventListener('click', onGenerate); }
|
||
if (genConfirmBtn) { genConfirmBtn.addEventListener('click', onGenConfirm); }
|
||
if (genCancelBtn) { genCancelBtn.addEventListener('click', onGenCancel); }
|
||
dialog.addEventListener('close', onDialogClose);
|
||
});
|
||
}
|
||
|
||
// ── Pick key file with automatic password prompt ────
|
||
function pickKeyFileWithPassword() {
|
||
return new Promise(function (resolve, reject) {
|
||
var input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = '.zddc-key';
|
||
input.addEventListener('change', async function () {
|
||
try {
|
||
var file = input.files && input.files[0];
|
||
if (!file) { resolve(null); return; }
|
||
var text = await file.text();
|
||
var jwk = await util.loadKeyFile(text, promptForPassword);
|
||
resolve(jwk);
|
||
} catch (err) {
|
||
reject(err);
|
||
}
|
||
});
|
||
input.click();
|
||
});
|
||
}
|
||
|
||
// ── Output checkbox helpers ───────────────────────────
|
||
function readOutputOptions() {
|
||
var saveCb = qs('#publish-save-folder');
|
||
var dlCb = qs('#publish-download-html');
|
||
return {
|
||
saveToFolder: saveCb && saveCb.checked,
|
||
download: dlCb && dlCb.checked
|
||
};
|
||
}
|
||
|
||
function syncOutputCheckboxes() {
|
||
var saveCb = qs('#publish-save-folder');
|
||
var dlCb = qs('#publish-download-html');
|
||
var hasDir = !!app.data.selectedDirHandle;
|
||
if (saveCb) { saveCb.checked = hasDir; }
|
||
if (dlCb) { dlCb.checked = !hasDir; }
|
||
}
|
||
|
||
function restoreOutputOptions(opts) {
|
||
var saveCb = qs('#publish-save-folder');
|
||
var dlCb = qs('#publish-download-html');
|
||
if (saveCb) { saveCb.checked = !!opts.saveToFolder; }
|
||
if (dlCb) { dlCb.checked = !!opts.download; }
|
||
}
|
||
|
||
function feedbackLabel(mode) {
|
||
if (mode === 'draft') { return 'Saving draft'; }
|
||
if (mode === 'signed') { return 'Signing and publishing'; }
|
||
return 'Publishing';
|
||
}
|
||
|
||
function setPublishBusy(busy) {
|
||
var btns = [qs('#publish-confirm'), qs('#publish-signed-btn'), qs('#publish-draft-btn')];
|
||
for (var i = 0; i < btns.length; i++) {
|
||
if (!btns[i]) { continue; }
|
||
btns[i].disabled = busy;
|
||
if (busy) { btns[i].classList.add('opacity-60'); }
|
||
else { btns[i].classList.remove('opacity-60'); }
|
||
}
|
||
}
|
||
|
||
// ── Core publish execution ──────────────────────────
|
||
async function executePublishFlow(mode, signingKey) {
|
||
var feedback = qs('#publish-modal-feedback');
|
||
var opts = readOutputOptions();
|
||
|
||
if (!opts.saveToFolder && !opts.download) {
|
||
throw new Error('Select at least one output option.');
|
||
}
|
||
|
||
// Prompt for directory if Save in directory is checked but none selected
|
||
if (opts.saveToFolder && !app.data.selectedDirHandle) {
|
||
try {
|
||
var dirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||
app.data.selectedDirHandle = dirHandle;
|
||
if (app.modules.files && app.modules.files.updateDirectoryIndicator) {
|
||
app.modules.files.updateDirectoryIndicator();
|
||
}
|
||
} catch (dirErr) {
|
||
throw new Error('A directory is required to save. Please select a directory.');
|
||
}
|
||
}
|
||
|
||
// Sync date from modal back to form
|
||
var dateInput = qs('#publish-date-input');
|
||
var formDate = qs('#date');
|
||
if (dateInput && formDate && dateInput.value) {
|
||
formDate.value = dateInput.value;
|
||
}
|
||
|
||
var publishModule = app.modules.publish;
|
||
if (!publishModule || typeof publishModule.executePublish !== 'function') {
|
||
throw new Error('Publish module not ready');
|
||
}
|
||
|
||
showFeedback(feedback, feedbackLabel(mode) + '\u2026', '');
|
||
|
||
var result = await publishModule.executePublish({
|
||
mode: mode,
|
||
saveToFolder: opts.saveToFolder,
|
||
download: opts.download,
|
||
signingKey: signingKey || null
|
||
});
|
||
|
||
return result;
|
||
}
|
||
|
||
// ── Publish modal ───────────────────────────────────
|
||
function initPublishModal() {
|
||
var modal = qs('#publish-modal');
|
||
if (!modal) { return; }
|
||
|
||
var feedback = qs('#publish-modal-feedback');
|
||
var confirmBtn = qs('#publish-confirm');
|
||
var signedBtn = qs('#publish-signed-btn');
|
||
var draftBtn = qs('#publish-draft-btn');
|
||
var dateInput = qs('#publish-date-input');
|
||
|
||
// Close buttons
|
||
modal.querySelectorAll('[data-modal-close]').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
if (modal.open) { modal.close(); }
|
||
});
|
||
});
|
||
|
||
// Backdrop click
|
||
modal.addEventListener('click', function (e) {
|
||
if (e.target === modal) { modal.close(); }
|
||
});
|
||
|
||
// Date warning
|
||
if (dateInput) { dateInput.addEventListener('input', syncDateWarning); }
|
||
|
||
function openModal() {
|
||
showFeedback(feedback, '', '');
|
||
syncOutputCheckboxes();
|
||
// Populate date
|
||
var formDate = qs('#date');
|
||
if (formDate && dateInput) {
|
||
var val = (formDate.value || '').trim();
|
||
dateInput.value = val ? toIsoDate(val) : getTodayIso();
|
||
}
|
||
syncDateWarning();
|
||
// Warn about missing hashes before user commits
|
||
var missingHash = 0;
|
||
(app.data.files || []).forEach(function (f) {
|
||
if (!f.sha256) { missingHash++; }
|
||
});
|
||
if (missingHash > 0) {
|
||
showFeedback(feedback, missingHash + ' file(s) have no SHA-256 hash.', 'error');
|
||
}
|
||
if (!modal.open) { modal.showModal(); }
|
||
}
|
||
|
||
function closeAndNotify(result) {
|
||
if (modal.open) { modal.close(); }
|
||
var msg = result.zipFilename ? result.filename + ' + ' + result.zipFilename : result.filename;
|
||
setTimeout(function () { showSuccessNotification(msg); }, 100);
|
||
}
|
||
|
||
function handleError(err) {
|
||
showFeedback(feedback, err && err.message ? err.message : 'Publish failed.', 'error');
|
||
if (err && err.focusEl && typeof err.focusEl.focus === 'function') {
|
||
if (modal.open) { modal.close(); }
|
||
err.focusEl.focus();
|
||
if (err.focusEl.scrollIntoView) { err.focusEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
|
||
}
|
||
}
|
||
|
||
// Publish (unsigned with digest) — primary action
|
||
if (confirmBtn) {
|
||
confirmBtn.addEventListener('click', async function () {
|
||
setPublishBusy(true);
|
||
try {
|
||
var result = await executePublishFlow('digest');
|
||
setPublishBusy(false);
|
||
closeAndNotify(result);
|
||
} catch (err) {
|
||
setPublishBusy(false);
|
||
handleError(err);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Publish Signed — opens key dialog first
|
||
if (signedBtn) {
|
||
signedBtn.addEventListener('click', async function () {
|
||
// Save user's checkbox selections before closing
|
||
var savedOpts = readOutputOptions();
|
||
if (modal.open) { modal.close(); }
|
||
|
||
try {
|
||
var jwk = await openKeyDialog();
|
||
if (!jwk) { openModal(); restoreOutputOptions(savedOpts); return; }
|
||
|
||
openModal();
|
||
restoreOutputOptions(savedOpts);
|
||
setPublishBusy(true);
|
||
var result = await executePublishFlow('signed', jwk);
|
||
setPublishBusy(false);
|
||
closeAndNotify(result);
|
||
} catch (err) {
|
||
setPublishBusy(false);
|
||
openModal();
|
||
restoreOutputOptions(savedOpts);
|
||
handleError(err);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Save Draft
|
||
if (draftBtn) {
|
||
draftBtn.addEventListener('click', async function () {
|
||
setPublishBusy(true);
|
||
try {
|
||
var result = await executePublishFlow('draft');
|
||
setPublishBusy(false);
|
||
closeAndNotify(result);
|
||
} catch (err) {
|
||
setPublishBusy(false);
|
||
handleError(err);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Listen for publish requests from the primary action button
|
||
document.addEventListener('transmittal:open-publish', function () {
|
||
openModal();
|
||
});
|
||
}
|
||
|
||
// ── Password dialog close buttons ───────────────────
|
||
function initPasswordDialog() {
|
||
var dialog = qs('#password-dialog');
|
||
if (!dialog) { return; }
|
||
dialog.querySelectorAll('[data-modal-close]').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
if (dialog.open) { dialog.close(); }
|
||
});
|
||
});
|
||
dialog.addEventListener('click', function (e) {
|
||
if (e.target === dialog) { dialog.close(); }
|
||
});
|
||
}
|
||
|
||
// ── Key dialog close buttons ────────────────────────
|
||
function initKeyDialog() {
|
||
var dialog = qs('#key-dialog');
|
||
if (!dialog) { return; }
|
||
dialog.querySelectorAll('[data-modal-close]').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
if (dialog.open) { dialog.close(); }
|
||
});
|
||
});
|
||
dialog.addEventListener('click', function (e) {
|
||
if (e.target === dialog) { dialog.close(); }
|
||
});
|
||
}
|
||
|
||
app.registerInit(function () {
|
||
initPublishModal();
|
||
initPasswordDialog();
|
||
initKeyDialog();
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const dom = app.dom;
|
||
const logosModule = app.modules.logos = {};
|
||
|
||
// Read an image file and apply it as a logo.
|
||
// imgId: 'left-logo' or 'right-logo'
|
||
async function applyLogoFile(imgId, file) {
|
||
if (!file) { return; }
|
||
var img = dom.qs('#' + imgId);
|
||
if (!img) { return; }
|
||
try {
|
||
var dataUrl = await new Promise(function (resolve, reject) {
|
||
var reader = new FileReader();
|
||
reader.onload = function () { resolve(reader.result); };
|
||
reader.onerror = function () { reject(reader.error); };
|
||
reader.readAsDataURL(file);
|
||
});
|
||
img.src = dataUrl;
|
||
// Mark logo-cell as having a logo so placeholder is hidden
|
||
var cell = img.closest('[data-drop-zone]');
|
||
if (cell) { cell.classList.add('has-logo'); }
|
||
app.state.apply();
|
||
} catch (err) {
|
||
console.error('[transmittal] logo apply failed', err);
|
||
alert('Failed to load logo: ' + (err && err.message ? err.message : err));
|
||
}
|
||
}
|
||
|
||
logosModule.applyLogoFile = applyLogoFile;
|
||
|
||
app.registerInit(function () {
|
||
// Nothing to wire here — drag-and-drop is handled by drop-zones.js.
|
||
// Keep the has-logo class in sync with existing logo src on load.
|
||
['left-logo', 'right-logo'].forEach(function (imgId) {
|
||
var img = dom.qs('#' + imgId);
|
||
if (!img) { return; }
|
||
var cell = img.closest('[data-drop-zone]');
|
||
if (cell && img.src && img.src !== window.location.href) {
|
||
cell.classList.add('has-logo');
|
||
}
|
||
});
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
// ── Targeted drop zones ─────────────────────────────────────────────────
|
||
// Shows contextual zone outlines when anything is dragged over the window.
|
||
// Emphasises zones that can accept the dragged data type.
|
||
// Hands off actual processing to existing module helpers.
|
||
|
||
var dropZonesModule = app.modules.dropZones = {};
|
||
|
||
// ── Data-transfer classification ────────────────────────────────────────
|
||
// Returns { hasImage, hasHtmlJson, hasPossibleDir }
|
||
// NOTE: MIME types ARE accessible on items during dragenter/dragover,
|
||
// but filenames are not. Directories have kind === 'file' and type === ''.
|
||
function classifyTransfer(dt) {
|
||
var hasImage = false;
|
||
var hasHtmlJson = false;
|
||
var hasPossibleDir = false;
|
||
|
||
if (!dt || !dt.items) {
|
||
return { hasImage: hasImage, hasHtmlJson: hasHtmlJson, hasPossibleDir: hasPossibleDir };
|
||
}
|
||
|
||
var items = dt.items;
|
||
for (var i = 0; i < items.length; i++) {
|
||
var item = items[i];
|
||
if (item.kind !== 'file') { continue; }
|
||
var t = (item.type || '').toLowerCase();
|
||
if (t.indexOf('image/') === 0) {
|
||
hasImage = true;
|
||
} else if (t === 'text/html' || t === 'application/json') {
|
||
hasHtmlJson = true;
|
||
} else if (t === '') {
|
||
// Could be a directory or a file with no MIME (e.g. .json on some OSes)
|
||
hasPossibleDir = true;
|
||
}
|
||
}
|
||
return { hasImage: hasImage, hasHtmlJson: hasHtmlJson, hasPossibleDir: hasPossibleDir };
|
||
}
|
||
|
||
// ── Eligibility per zone type ───────────────────────────────────────────
|
||
function zoneIsEligible(zoneType, cls) {
|
||
var inEditMode = (app.state && app.state.mode === 'edit');
|
||
switch (zoneType) {
|
||
case 'logo-left':
|
||
case 'logo-right':
|
||
return cls.hasImage && inEditMode;
|
||
case 'header':
|
||
// Accept HTML/JSON imports; also allow unknown-type files (hasPossibleDir)
|
||
// so that dragging a JSON with no MIME still lights up the header zone.
|
||
return (cls.hasHtmlJson || cls.hasPossibleDir) && inEditMode;
|
||
case 'file-table':
|
||
// Directories and HTML/JSON imports are always eligible here.
|
||
// (Verify works in both edit and published mode.)
|
||
// Pure image-only drops are not accepted here.
|
||
return cls.hasHtmlJson || cls.hasPossibleDir;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ── Zone visibility helpers ─────────────────────────────────────────────
|
||
function getAllZones() {
|
||
return document.querySelectorAll('[data-drop-zone]');
|
||
}
|
||
|
||
function showZones(cls) {
|
||
getAllZones().forEach(function (el) {
|
||
var zoneType = el.getAttribute('data-drop-zone');
|
||
var eligible = zoneIsEligible(zoneType, cls);
|
||
el.classList.add('dz-visible');
|
||
el.classList.toggle('dz-eligible', eligible);
|
||
el.classList.toggle('dz-ineligible', !eligible);
|
||
});
|
||
}
|
||
|
||
function hideZones() {
|
||
getAllZones().forEach(function (el) {
|
||
el.classList.remove('dz-visible', 'dz-eligible', 'dz-ineligible', 'dz-hover');
|
||
});
|
||
}
|
||
|
||
// ── Window-level drag tracking ──────────────────────────────────────────
|
||
// Use relatedTarget to distinguish real enter/leave from child-element noise.
|
||
// relatedTarget is null when the drag crosses the window boundary.
|
||
// Note: this approach is reliable in Chromium-based browsers. Firefox has
|
||
// a known quirk where relatedTarget may not be null at true window boundary
|
||
// in all cases; zones may not appear in Firefox. Acceptable given the app's
|
||
// primary target is Chromium.
|
||
|
||
document.addEventListener('dragenter', function (e) {
|
||
e.preventDefault();
|
||
if (e.relatedTarget === null) {
|
||
showZones(classifyTransfer(e.dataTransfer));
|
||
}
|
||
});
|
||
|
||
document.addEventListener('dragleave', function (e) {
|
||
if (e.relatedTarget === null) {
|
||
hideZones();
|
||
}
|
||
});
|
||
|
||
document.addEventListener('dragover', function (e) { e.preventDefault(); });
|
||
|
||
document.addEventListener('drop', function (e) {
|
||
// Default handler: prevent browser navigation. Specific zones handle drops.
|
||
e.preventDefault();
|
||
hideZones();
|
||
});
|
||
|
||
// ── Per-zone drop handler: HTML / JSON import ───────────────────────────
|
||
async function handleDataFileDrop(file, sourceName) {
|
||
var dataModule = app.modules.data;
|
||
if (!dataModule) { return; }
|
||
var name = (sourceName || '').toLowerCase();
|
||
try {
|
||
var text = await file.text();
|
||
var data = null;
|
||
if (name.endsWith('.json')) {
|
||
data = JSON.parse(text);
|
||
} else {
|
||
// HTML: extract embedded JSON using the shared helper from data.js
|
||
if (dataModule.extractJsonFromHtml) {
|
||
data = dataModule.extractJsonFromHtml(text);
|
||
}
|
||
}
|
||
if (!data) {
|
||
if (dataModule.setStatus) { dataModule.setStatus('Dropped file does not contain transmittal data', 'error'); }
|
||
return;
|
||
}
|
||
await dataModule.applyLoadedData(data, sourceName || 'dropped');
|
||
} catch (err) {
|
||
console.error('[transmittal] drop-zones: file drop failed', err);
|
||
if (dataModule.setStatus) {
|
||
dataModule.setStatus('Failed to import: ' + (err && err.message ? err.message : err), 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Per-zone drop handler: directory ────────────────────────────────────
|
||
async function handleDirectoryDrop(item) {
|
||
var dataModule = app.modules.data;
|
||
var filesModule = app.modules.files;
|
||
if (!item || typeof item.getAsFileSystemHandle !== 'function') { return false; }
|
||
try {
|
||
var handle = await item.getAsFileSystemHandle();
|
||
if (!handle || handle.kind !== 'directory') { return false; }
|
||
if (typeof handle.requestPermission === 'function') {
|
||
await handle.requestPermission({ mode: 'readwrite' });
|
||
}
|
||
app.data.selectedDirHandle = handle;
|
||
if (filesModule && filesModule.updateDirectoryIndicator) {
|
||
filesModule.updateDirectoryIndicator();
|
||
}
|
||
if (dataModule && dataModule.setStatus) {
|
||
dataModule.setStatus('Directory: ' + handle.name, 'info');
|
||
}
|
||
var isPublished = !!app.state.published;
|
||
var hasFiles = app.data.files && app.data.files.length > 0;
|
||
if (isPublished || hasFiles) {
|
||
document.dispatchEvent(new CustomEvent('transmittal:verify-directory'));
|
||
} else {
|
||
document.dispatchEvent(new CustomEvent('transmittal:scan-directory'));
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
console.warn('[transmittal] drop-zones: getAsFileSystemHandle failed', err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ── Wire a single zone element ──────────────────────────────────────────
|
||
function wireZone(el) {
|
||
var zoneType = el.getAttribute('data-drop-zone');
|
||
|
||
el.addEventListener('dragover', function (e) {
|
||
var cls = classifyTransfer(e.dataTransfer);
|
||
if (zoneIsEligible(zoneType, cls)) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
el.classList.add('dz-hover');
|
||
}
|
||
});
|
||
|
||
el.addEventListener('dragleave', function (e) {
|
||
// Only remove hover if we're actually leaving this element
|
||
// (not just moving to a child element within it)
|
||
if (!el.contains(e.relatedTarget)) {
|
||
el.classList.remove('dz-hover');
|
||
}
|
||
});
|
||
|
||
el.addEventListener('drop', async function (e) {
|
||
var cls = classifyTransfer(e.dataTransfer);
|
||
if (!zoneIsEligible(zoneType, cls)) { return; }
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
hideZones();
|
||
|
||
// ── Logo zones ──────────────────────────────────────────
|
||
if (zoneType === 'logo-left' || zoneType === 'logo-right') {
|
||
var file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||
if (!file) { return; }
|
||
var imgId = zoneType === 'logo-left' ? 'left-logo' : 'right-logo';
|
||
if (app.modules.logos && app.modules.logos.applyLogoFile) {
|
||
await app.modules.logos.applyLogoFile(imgId, file);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ── Header zone: HTML or JSON import only ───────────────
|
||
if (zoneType === 'header') {
|
||
var file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||
if (!file) { return; }
|
||
var name = (file.name || '').toLowerCase();
|
||
if (!name.endsWith('.html') && !name.endsWith('.htm') && !name.endsWith('.json')) {
|
||
if (app.modules.data && app.modules.data.setStatus) {
|
||
app.modules.data.setStatus('Drop an HTML or JSON transmittal file here', 'error');
|
||
}
|
||
return;
|
||
}
|
||
await handleDataFileDrop(file, file.name);
|
||
return;
|
||
}
|
||
|
||
// ── File-table zone: directory or HTML/JSON ─────────────
|
||
if (zoneType === 'file-table') {
|
||
var ftItems = e.dataTransfer && e.dataTransfer.items;
|
||
var ftFirstItem = ftItems && ftItems.length > 0 ? ftItems[0] : null;
|
||
// Grab file synchronously before async work
|
||
var ftFile = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||
|
||
// Try directory first
|
||
if (ftFirstItem) {
|
||
var wasDir = await handleDirectoryDrop(ftFirstItem);
|
||
if (wasDir) { return; }
|
||
}
|
||
|
||
// Fall back to file-based import
|
||
if (!ftFile) { return; }
|
||
var ftName = (ftFile.name || '').toLowerCase();
|
||
if (!ftName.endsWith('.html') && !ftName.endsWith('.htm') && !ftName.endsWith('.json')) {
|
||
if (app.modules.data && app.modules.data.setStatus) {
|
||
app.modules.data.setStatus('Drop a folder, HTML, or JSON file here', 'error');
|
||
}
|
||
return;
|
||
}
|
||
await handleDataFileDrop(ftFile, ftFile.name);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Initialization ──────────────────────────────────────────────────────
|
||
app.registerInit(function () {
|
||
document.querySelectorAll('[data-drop-zone]').forEach(wireZone);
|
||
});
|
||
|
||
dropZonesModule.classifyTransfer = classifyTransfer;
|
||
dropZonesModule.zoneIsEligible = zoneIsEligible;
|
||
|
||
})(window.transmittalApp);
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
app.registerInit(function () {
|
||
let tabbing = false;
|
||
|
||
document.addEventListener('keydown', function (event) {
|
||
if (event.key === 'Tab') {
|
||
tabbing = true;
|
||
}
|
||
}, true);
|
||
|
||
document.addEventListener('mousedown', function () {
|
||
tabbing = false;
|
||
}, true);
|
||
|
||
document.addEventListener('focusin', function (event) {
|
||
if (!tabbing) {
|
||
return;
|
||
}
|
||
tabbing = false;
|
||
const target = event.target;
|
||
if (!target) {
|
||
return;
|
||
}
|
||
if (target.id === 'remarks') {
|
||
return;
|
||
}
|
||
if (target instanceof HTMLInputElement) {
|
||
try {
|
||
target.select();
|
||
} catch (_) {
|
||
// ignore selection issues
|
||
}
|
||
return;
|
||
}
|
||
if (target instanceof HTMLTextAreaElement) {
|
||
return;
|
||
}
|
||
if (target.isContentEditable) {
|
||
try {
|
||
const selection = window.getSelection && window.getSelection();
|
||
if (selection && document.createRange) {
|
||
const range = document.createRange();
|
||
range.selectNodeContents(target);
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
}
|
||
} catch (_) {
|
||
// ignore
|
||
}
|
||
}
|
||
}, true);
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
/**
|
||
* 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();
|
||
}
|
||
}());
|
||
|
||
(function (app) {
|
||
'use strict';
|
||
|
||
const filesModule = app.modules.files;
|
||
const security = app.modules.security;
|
||
const mode = app.modules.mode;
|
||
|
||
// Global handler: close parent <dialog> when any [data-modal-close] button is clicked
|
||
document.addEventListener('click', function (e) {
|
||
var btn = e.target.closest('[data-modal-close]');
|
||
if (!btn) { return; }
|
||
var dlg = btn.closest('dialog');
|
||
if (dlg && dlg.open) { dlg.close(); }
|
||
});
|
||
|
||
// ── Date field + calendar picker wiring ────────────
|
||
function toIso(str) {
|
||
if (!str || !str.trim()) { return ''; }
|
||
var t = str.trim();
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(t)) { return t; }
|
||
var d = new Date(t);
|
||
if (!isNaN(d.getTime())) {
|
||
var y = d.getFullYear();
|
||
var m = String(d.getMonth() + 1).padStart(2, '0');
|
||
var day = String(d.getDate()).padStart(2, '0');
|
||
return y + '-' + m + '-' + day;
|
||
}
|
||
return t;
|
||
}
|
||
|
||
function wireDatePicker(textId, pickerId) {
|
||
var dateText = app.dom.qs(textId);
|
||
var datePicker = app.dom.qs(pickerId);
|
||
if (!dateText || !datePicker) { return; }
|
||
|
||
dateText.addEventListener('click', function () {
|
||
var iso = toIso(dateText.value);
|
||
datePicker.value = (iso && /^\d{4}-\d{2}-\d{2}$/.test(iso)) ? iso : '';
|
||
datePicker.showPicker();
|
||
});
|
||
|
||
datePicker.addEventListener('change', function () {
|
||
if (datePicker.value) {
|
||
dateText.value = datePicker.value;
|
||
dateText.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
});
|
||
|
||
dateText.addEventListener('blur', function () {
|
||
var iso = toIso(dateText.value);
|
||
if (iso !== dateText.value) {
|
||
dateText.value = iso;
|
||
dateText.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
});
|
||
}
|
||
|
||
wireDatePicker('#date', '#date-picker-hidden');
|
||
wireDatePicker('#response-due', '#response-due-picker-hidden');
|
||
|
||
app.registerInit(async function () {
|
||
// ── Type combo dropdown wiring ───────────────────────
|
||
(function () {
|
||
var typeHidden = app.dom.qs('#type');
|
||
var typeDisplay = app.dom.qs('#type-display');
|
||
var menu = app.dom.qs('#type-menu');
|
||
if (!typeHidden || !typeDisplay || !menu) { return; }
|
||
|
||
function syncToHidden() {
|
||
var text = (typeDisplay.textContent || '').trim();
|
||
typeHidden.value = text;
|
||
typeHidden.dispatchEvent(new Event('input', { bubbles: true }));
|
||
if (app.state && app.state.apply) { app.state.apply(); }
|
||
}
|
||
|
||
function isOpen() { return !menu.classList.contains('hidden'); }
|
||
function closeMenu() { menu.classList.add('hidden'); }
|
||
|
||
typeDisplay.addEventListener('input', syncToHidden);
|
||
|
||
typeDisplay.addEventListener('blur', syncToHidden);
|
||
|
||
typeDisplay.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
typeDisplay.blur();
|
||
}
|
||
});
|
||
|
||
typeDisplay.addEventListener('paste', function (e) {
|
||
e.preventDefault();
|
||
var text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||
document.execCommand('insertText', false, text.replace(/\n/g, ' '));
|
||
});
|
||
|
||
typeDisplay.addEventListener('click', function () {
|
||
if (app.state.mode !== 'edit') { return; }
|
||
if (isOpen()) { closeMenu(); } else { menu.classList.remove('hidden'); }
|
||
});
|
||
|
||
menu.addEventListener('click', function (e) {
|
||
var opt = e.target.closest('[data-value]');
|
||
if (!opt) { return; }
|
||
var val = opt.getAttribute('data-value');
|
||
typeDisplay.textContent = val;
|
||
typeHidden.value = val;
|
||
typeHidden.dispatchEvent(new Event('input', { bubbles: true }));
|
||
closeMenu();
|
||
if (app.state && app.state.apply) { app.state.apply(); }
|
||
});
|
||
|
||
document.addEventListener('click', function (e) {
|
||
if (isOpen() && !menu.contains(e.target) && e.target !== typeDisplay) {
|
||
closeMenu();
|
||
}
|
||
});
|
||
})();
|
||
filesModule.loadFromJson();
|
||
app._initialized = true;
|
||
app.state.updateHiddenFields();
|
||
app.state.apply();
|
||
await security.verifySignatureIfPresent();
|
||
mode.refresh();
|
||
});
|
||
|
||
app.ready(function () {
|
||
app.start();
|
||
});
|
||
})(window.transmittalApp);
|
||
|
||
</script>
|
||
<script id="help-markdown" type="application/markdown">
|
||
# Transmittal Creator
|
||
|
||
[← Back to ZDDC](../README.md)
|
||
|
||
Create professional document transmittals that are impossible to forge or tamper with. Each transmittal is a self-contained HTML file with built-in integrity checking and optional digital signatures. Email it, archive it, trust it.
|
||
|
||
**[🔗 Open Transmittal Creator](dist/transmittal.html)** - Click to use online, or right-click → "Save Link As" to keep your own copy.
|
||
|
||
## The "Record Player with the Record" Concept
|
||
|
||
This tool embodies true data portability - each transmittal contains both the data AND the viewer. Recipients don't need special software, accounts, or training. They just open the HTML file in any browser to see a professional transmittal with full verification capabilities. In 20 years, it will still work exactly the same.
|
||
|
||
## What Makes Transmittals Special?
|
||
|
||
✅ **Tamper-Proof**
|
||
Every file gets a SHA-256 fingerprint. If even one character changes, it's detected instantly.
|
||
|
||
🔒 **Digitally Signable**
|
||
Optionally sign with cryptographic keys. Proves WHO sent it and WHEN, forever.
|
||
|
||
📧 **Self-Contained**
|
||
The entire transmittal is one HTML file. Email it, archive it, open it anywhere.
|
||
|
||
📊 **Machine-Readable**
|
||
Embedded JSON data means your systems can parse and process automatically.
|
||
|
||
🔍 **Independently Verifiable**
|
||
Anyone can verify signatures and checksums - but verification MUST be done using a trusted copy of this tool, not the transmittal itself. See [Verification Security](#verification-security) below.
|
||
|
||
## Quick Start
|
||
|
||
### Workflow A: Paste file list first (typical for new transmittals)
|
||
|
||
1. **Fill in the header** — Tracking Number, Date, To, From, Subject, Purpose, Remarks
|
||
2. **Create Folder** — Menu → Create Folder → select staging directory → folder is created and selected
|
||
3. **Paste file list** — Copy 3-5 adjacent columns from Excel (Tracking, Title, Revision, [Status], [Extension]) → Menu → Paste New Rows
|
||
4. **Drop files onto rows** — Drag individual files from your OS onto matching rows to copy with ZDDC names and compute hashes
|
||
5. **Save Draft** — Menu → Save Draft → save into the created folder
|
||
6. **Publish** — When ready, Menu → Publish → choose Unsigned or Signed
|
||
|
||
### Workflow B: Scan an existing directory
|
||
|
||
1. **Click "Select Directory"** — Choose folder with files to transmit
|
||
2. **Files auto-populate** — With tracking numbers, revisions, and checksums parsed from ZDDC filenames
|
||
3. **Fill in the form** — To, From, Subject, and any remarks
|
||
4. **Click Publish** — Choose Draft, Unsigned, or Signed
|
||
5. **Send the HTML file** — Email it or save to your archive
|
||
|
||
## Verification Security
|
||
|
||
⚠️ **CRITICAL: Self-Verification is NOT Secure**
|
||
|
||
When you open a transmittal, it may display "✓ Signature Valid" - but **this display can be faked**. A malicious actor could create a transmittal that shows valid signatures while containing altered data.
|
||
|
||
### How to Verify Securely
|
||
|
||
**For Document Controllers / Official Verification:**
|
||
|
||
1. **Use a trusted tool instance** - Download the official transmittal tool from a trusted source (e.g., your organization's approved version or https://zddc.varasys.io/releases/transmittal_stable.html)
|
||
2. **Export JSON from the transmittal** - Open the transmittal → Click "Download Data"
|
||
3. **Import JSON into trusted tool** - Open your trusted tool → Click "Load JSON" → Paste the exported data
|
||
4. **Verify file hashes** - Click "Select Directory" and point to the actual files
|
||
5. **Check the verification display** - Only trust the verification shown in YOUR trusted tool, not the transmittal itself
|
||
|
||
**Why This Works:**
|
||
- The trusted tool's code is verified (by you or your organization)
|
||
- The JSON data is extracted and re-verified independently
|
||
- File hashes are computed fresh from the actual files
|
||
- The verification logic cannot be tampered with
|
||
|
||
**For Casual Review:**
|
||
- The self-verification display is fine for informal checks
|
||
- Useful for catching accidental modifications
|
||
- NOT sufficient for legal/contractual verification
|
||
|
||
### Verification Workflow
|
||
|
||
```
|
||
Received Transmittal (untrusted)
|
||
↓
|
||
Export JSON Data
|
||
↓
|
||
Trusted Tool Instance (verified by your organization)
|
||
↓
|
||
Import JSON → Verify Signatures → Verify File Hashes
|
||
↓
|
||
Official Verification Result
|
||
```
|
||
|
||
**Best Practice:** Organizations should maintain a verified copy of the transmittal tool at a known URL or network location for all document controllers to use for official verification.
|
||
|
||
## Technical Architecture
|
||
|
||
### Data-First Design
|
||
- **Single source of truth**: `<script id="transmittal-data" type="application/json">` block
|
||
- All UI state derived from this embedded JSON
|
||
- No duplicate data storage in the HTML
|
||
|
||
### Build System
|
||
- **Build script** that:
|
||
- Concatenates modular CSS and JavaScript files
|
||
- Inlines everything into template.html
|
||
- Embeds this README as help documentation
|
||
- Outputs single `dist/transmittal.html` file
|
||
|
||
### Security Model
|
||
- All cryptographic operations happen **client-side** (Web Crypto API)
|
||
- Private keys never stored or uploaded
|
||
- **Self-verification is informational only** - a malicious transmittal can fake verification displays
|
||
- **Secure verification requires external validation** - see [Verification Security](#verification-security)
|
||
- Organizations should maintain a trusted tool instance for official verification
|
||
|
||
## Key Features
|
||
|
||
✅ **Portable**: Single HTML file works offline
|
||
✅ **Cryptographic**: SHA-256 hashing + ECDSA signatures
|
||
✅ **Parseable**: Embedded JSON for machine automation
|
||
✅ **Human-readable**: Clean UI with print optimization
|
||
✅ **Filterable**: Boolean logic filtering on all columns
|
||
✅ **Verifiable**: Independent signature verification
|
||
✅ **Standards-based**: ZDDC filename convention compliance
|
||
|
||
## Use Case
|
||
|
||
This tool is designed for **engineering/construction document transmittals** where:
|
||
- Multiple files need to be transmitted with metadata
|
||
- Cryptographic integrity verification is required
|
||
- Non-repudiation via digital signatures is valuable
|
||
- Recipients need human-readable + machine-parseable format
|
||
- Offline/portable distribution is essential
|
||
|
||
The project achieves a **self-contained, verifiable document package** that bridges human workflow needs with automation requirements.
|
||
|
||
## How It Works
|
||
|
||
The transmittal operates on a simple principle: all data lives in a single JSON block inside the HTML. When you save or publish, it updates this JSON and downloads a new HTML file. This ensures:
|
||
|
||
- Complete data portability
|
||
- No hidden state or complexity
|
||
- Easy integration with other systems
|
||
- Full transparency of what's stored
|
||
|
||
|
||
## Field reference (canonical payload)
|
||
|
||
Form values map to a stable canonical payload used for hashing/signing and JSON export. This payload lives inside the `<script type="application/json" id="transmittal-data">` block.
|
||
|
||
**JSON Schema**: See [`transmittal.schema.json`](./transmittal/transmittal.schema.json) for the complete, machine-readable schema definition.
|
||
|
||
### Key Fields
|
||
|
||
**Payload** (core document data):
|
||
- `version` — Payload schema version (currently 1)
|
||
- `type` — Document type: "Transmittal", "Submittal", or custom text (e.g., "Contract Attachment 1")
|
||
- `title` — Optional descriptive title
|
||
- `client` — The Owner Name
|
||
- `project` — The Project Name
|
||
- `projectNumber` — The Project Number
|
||
- `date` — Date in YYYY-MM-DD format (canonicalized from friendly text)
|
||
- `trackingNumber` — Must not contain spaces or underscores
|
||
- `from` — Who issued the document (hidden when type is not Transmittal or Submittal)
|
||
- `to` — Recipient (hidden when type is not Transmittal or Submittal)
|
||
- `purpose` — Purpose of document (hidden when type is not Transmittal or Submittal)
|
||
- `responseDue` — Response deadline (shown only when type is Submittal)
|
||
- `subject` — Subject line
|
||
- `remarks` — Free-form Markdown content
|
||
- `files[]` — Array of file objects, each containing:
|
||
- `trackingNumber`, `revision`, `status`, `title` (parsed from ZDDC filename)
|
||
- `path` — Directory path relative to transmittal root (empty string for root-level files)
|
||
- `filename` — Filename only (without path)
|
||
- `sha256` — SHA-256 hash of file contents (hex)
|
||
- `fileSize` — File size in bytes
|
||
|
||
**Envelope** (cryptographic metadata):
|
||
- `version` — Envelope schema version (currently 1)
|
||
- `digestAlgorithm` — Always "SHA-256"
|
||
- `digest` — SHA-256 hash of canonical payload string (hex)
|
||
- `digestedAt` — ISO 8601 timestamp when digest was computed
|
||
- `signatureAlgorithm` — "ECDSA-P256-SHA256"
|
||
- `signatures[]` — Array of signature objects, each containing:
|
||
- `signature` — ECDSA signature over canonical envelope (base64)
|
||
- `signedAt` — ISO 8601 timestamp when this signature was created
|
||
- `publicKeyJwk` — EC P-256 public key (JWK) for verification
|
||
|
||
**Security Model**: Signatures sign the envelope (which contains the digest), not the payload directly. This prevents tampering with the digest field while maintaining a two-layer verification:
|
||
1. **Digest verification**: Confirms payload hasn't been modified (fast, no crypto)
|
||
2. **Signature verification**: Confirms envelope (including digest) is authentic (cryptographic proof)
|
||
|
||
**Presentation** (optional display assets - not signed):
|
||
- `leftLogo` — Left logo as data URL
|
||
- `rightLogo` — Right logo as data URL
|
||
- `theme` — Visual theme identifier
|
||
- `customCss` — Optional custom CSS for styling
|
||
|
||
|
||
## Files table
|
||
|
||
**Columns**: Tracking Number, Title, Revision, Status, EXT, Size, SHA256
|
||
|
||
**Column visibility**:
|
||
- **Default view**: Shows Tracking Number, Title, Revision, Status (essential columns)
|
||
- **Show Details**: Toggle to reveal EXT, Size, SHA256 (technical details)
|
||
- **Fullscreen**: Expand table to full browser window for better viewing of large file lists
|
||
|
||
**Pasting file lists**:
|
||
- Copy 3-5 **adjacent** tab-separated columns from Excel or any spreadsheet
|
||
- Expected column order: Tracking, Title, Revision, [Status], [Extension]
|
||
- 3-column paste: third column can be combined "A (IFR)" → splits into Revision "A" and Status "IFR"
|
||
- Pastes with more than 5 columns are rejected with an error — condition your data into adjacent columns first
|
||
- **Paste New Rows**: Replaces entire file list (confirms first)
|
||
- **Paste Append Rows**: Adds to existing list, updates matching rows by tracking+revision key
|
||
|
||
**Drop file onto row**:
|
||
- Drag a file from your OS onto any table row in edit mode
|
||
- The file is copied to the selected directory with the proper ZDDC filename
|
||
- SHA-256 hash is computed and stored
|
||
- If no directory is selected, you will be prompted to pick one
|
||
|
||
**File scanning**:
|
||
- "Select Directory" / "Scan Directory" scans recursively using the File System Access API, hashing files locally and parsing names
|
||
- Filename parser expects: `trackingNumber_revision (status) - title.ext`
|
||
- Example: `A101-203_revA (Issued) - Site Plan.pdf`
|
||
- If a filename doesn't match, it still appears; parsed fields default to empty and `title` becomes the base name
|
||
- Imports are merged on key `(trackingNumber|revision)`; newer scans update/replace matching rows
|
||
- For known viewable types (`pdf,png,jpg,jpeg,gif,svg,webp,txt,html,htm,md`), links open in a new tab; others download
|
||
|
||
|
||
## Filtering
|
||
|
||
Each column has a filter input. Supported expression syntax:
|
||
|
||
| Expression | Meaning |
|
||
|---|---|
|
||
| `term` | Contains "term" (case-insensitive) |
|
||
| `!term` | Does not contain "term" |
|
||
| `^term` | Starts with "term" |
|
||
| `term$` | Ends with "term" |
|
||
| `a b` | Matches both (AND) |
|
||
| `a \| b` | Matches either (OR) |
|
||
| `^IFA \| ^IFB` | Starts with IFA or IFB |
|
||
| `pdf !draft` | Contains "pdf" and not "draft" |
|
||
| `!^~` | Does not start with ~ (excludes drafts) |
|
||
| `el.*spc` | Regex: "el" followed by "spc" |
|
||
| `[ei]fa` | Regex character class: "efa" or "ifa" |
|
||
|
||
Examples:
|
||
|
||
- Only PDFs: in EXT filter, type `pdf$`.
|
||
- Exclude drafts: in Revision, type `!^~`.
|
||
- Status IFA or IFB: type `^IFA | ^IFB`.
|
||
- Title contains both "floor" and "plan": type `floor plan`.
|
||
- Tracking contains "el" then "spc": type `el.*spc`.
|
||
|
||
|
||
## Form validation
|
||
|
||
The transmittal provides real-time validation to catch errors early:
|
||
|
||
**Inline validation** (as you type):
|
||
- **Tracking Number**: Highlights if contains spaces or underscores (invalid characters)
|
||
- **Date fields**: Auto-formats to YYYY-MM-DD, shows format hint on focus
|
||
- **File table**: Shows parsing warnings for filenames that don't match ZDDC convention
|
||
|
||
**Publish-time validation**:
|
||
- All required fields must be filled
|
||
- Tracking numbers must not contain spaces or underscores
|
||
- File revisions must not contain spaces
|
||
- At least one file must be included
|
||
|
||
**Smart defaults**:
|
||
- **Date**: Pre-filled with today's date in YYYY-MM-DD format
|
||
|
||
|
||
## Signature verification
|
||
|
||
⚠️ **WARNING: For official verification, see [Verification Security](#verification-security) above. The verification display shown here can be faked by a malicious transmittal.**
|
||
|
||
When viewing a published transmittal, signature status is displayed above the files table:
|
||
|
||
**For each signature**:
|
||
- ✓ **Valid** — Signature verified successfully (in this document's context)
|
||
- ⚠ **Warning** — Signature valid but transmittal has been edited
|
||
- ✗ **Invalid** — Signature verification failed
|
||
|
||
**Verification Actions**:
|
||
- **Verify Externally** — Opens a trusted tool instance for independent verification
|
||
- Copies JSON data to clipboard
|
||
- Opens https://zddc.varasys.io/releases/transmittal_stable.html in validation mode
|
||
- Paste JSON and select directory to verify file hashes independently
|
||
- Only trust verification results from the trusted tool, not this document
|
||
|
||
**Signature details** (expandable):
|
||
- Public key fingerprint (click to copy)
|
||
- Signature timestamp (from `signedAt` field)
|
||
- Signature index (e.g., "Signature 1 of 3")
|
||
|
||
**Other Actions**:
|
||
- **Delete signature** — Remove invalid or outdated signatures
|
||
- **Add signature** — Co-sign the document with your key
|
||
- Opens signing dialog (select existing key or generate new key)
|
||
- New signature appended to `signatures[]` array with current timestamp
|
||
- Downloads updated document (cannot save in place)
|
||
|
||
|
||
## Saving and loading transmittals
|
||
|
||
**Save Draft** (Menu → Save Draft):
|
||
- Saves current state as an HTML file via the system file picker
|
||
- All header info, file list, and remarks are preserved
|
||
- Open the saved HTML in a browser to resume editing
|
||
- Drafts have no digest or signature
|
||
|
||
**Create Folder** (Menu → Create Folder):
|
||
- Builds a folder name from the form: `YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT`
|
||
- Prompts you to select a staging directory, then creates the subfolder
|
||
- The new folder becomes the selected directory for file drops and Save Draft
|
||
- Requires at least a tracking number
|
||
|
||
**Remove Files** (Menu → Remove Files):
|
||
- Clears the file list while keeping all header info and remarks
|
||
- Useful when reusing a transmittal template with a new set of files
|
||
|
||
**Save as JSON**:
|
||
- Click **Download Data** to export the current transmittal as JSON
|
||
- This file can be reloaded later as either:
|
||
- A finished transmittal (view/verify)
|
||
- A starting point for a new transmittal (edit/modify)
|
||
|
||
**Load from JSON**:
|
||
- Click **Load JSON** to import a previously saved transmittal
|
||
- All form fields, files, and presentation assets (logos) are restored
|
||
|
||
**Import HTML** (Menu → Import HTML):
|
||
- Load a previously saved draft or published transmittal HTML file
|
||
- Extracts the embedded JSON and restores all fields
|
||
- You can also drag-and-drop an HTML or JSON file directly onto the page — see **Drag-and-Drop Zones** below
|
||
|
||
## Drag-and-Drop Zones
|
||
|
||
Whenever you drag any file or folder over the browser window, labelled drop zones appear on the transmittal to show you exactly where data can land:
|
||
|
||
| Zone | Location | Accepts | Notes |
|
||
|------|----------|---------|-------|
|
||
| **Sender logo** | Top-left logo area | Image files (png, jpg, svg, …) | Edit mode only |
|
||
| **Receiver logo** | Top-right logo area | Image files | Edit mode only |
|
||
| **Header** | Header info block | Transmittal HTML or JSON file | Imports all header fields and file list; edit mode only |
|
||
| **File table** | Document table area | Folder (scan or verify) · Transmittal HTML or JSON | Works in both edit and published mode |
|
||
|
||
**Visual feedback during drag:**
|
||
- Zones that can accept the dragged data are highlighted with a blue dashed outline and a descriptive label.
|
||
- Zones that cannot accept the data are shown with a grey outline and dimmed.
|
||
- When you hover over an eligible zone, it brightens further to confirm where the drop will land.
|
||
|
||
**Logos and presentation assets**:
|
||
- Logos ARE included in JSON exports (in the optional `presentation` object)
|
||
- Logos are NOT part of the signed `payload` (separate top-level object)
|
||
- When you drag-drop logos, they're converted to data URLs
|
||
- The `presentation` object includes:
|
||
- `leftLogo` and `rightLogo` (data URLs)
|
||
- `theme` (visual theme identifier)
|
||
- `customCss` (optional custom styling)
|
||
- This keeps the cryptographic signature focused on document data, not visual styling
|
||
|
||
|
||
## JSON export format
|
||
|
||
Download Data exports the exact contents of the embedded JSON block.
|
||
|
||
**Format**: See [`transmittal.schema.json`](./transmittal/transmittal.schema.json) for the complete structure.
|
||
|
||
**Key Points**:
|
||
- The `payload` contains all transmittal data (metadata + files)
|
||
- The `envelope` contains cryptographic metadata (digest, timestamp, signatures)
|
||
- The `presentation` (optional) contains display assets (logos, styling) - **not signed**
|
||
- The digest is computed over the canonical JSON string of `payload` only (sorted keys, stable formatting)
|
||
- Each signature in the `signatures[]` array signs the same canonical payload string
|
||
- If unsigned, the `signatures` array is empty `[]`
|
||
- Presentation assets travel with the JSON but are excluded from cryptographic operations
|
||
|
||
|
||
## Browser compatibility and printing
|
||
|
||
- Directory selection requires the File System Access API (Chromium‑based browsers). On unsupported browsers, file scanning will not work.
|
||
- Print styles are optimized for US Letter (8.5in×11in). Table header "sticky" behavior is disabled in print.
|
||
|
||
|
||
## Publishing & Signing
|
||
|
||
When you click **Publish**, a modal opens with these options:
|
||
|
||
1. **Save Draft** — Saves HTML with current data (no digest, no signature)
|
||
- Also available directly from Menu → Save Draft (without opening the publish modal)
|
||
- Use for work-in-progress transmittals
|
||
- Can be reopened and edited later
|
||
|
||
2. **Publish Unsigned** — Creates transmittal with digest only (no signature)
|
||
- Provides integrity verification via SHA-256 digest
|
||
- Proves content hasn't been modified
|
||
- No identity authentication
|
||
|
||
3. **Publish Signed** — Creates transmittal with digest + signature(s)
|
||
- Choose one of:
|
||
- **Sign with Existing Key** — Select your saved private key JWK file
|
||
- **Sign with New Key** — Generate new EC P-256 key pair (downloads private key for you to save)
|
||
- Provides both integrity and authentication
|
||
- Supports multiple signatures (co-signing)
|
||
|
||
**Security notes**:
|
||
- All cryptographic operations happen locally in the browser (Web Crypto API)
|
||
- Private keys are never stored or uploaded
|
||
- Only public keys are embedded in the published transmittal for verification
|
||
- If you generate a new key, **save the downloaded private key file** — you'll need it to sign future transmittals with the same identity
|
||
- Editing a published transmittal invalidates existing signatures (they can be deleted)
|
||
|
||
## Workflow
|
||
|
||
The transmittal operates in different modes that control what actions are available:
|
||
|
||
### Edit Mode (Default)
|
||
|
||
**When active**: New transmittals or when explicitly toggled to Edit mode.
|
||
|
||
**Document type selector**:
|
||
|
||
The `type` field is a text input with autocomplete suggestions. Type to select or enter custom text.
|
||
|
||
| Field | Transmittal | Submittal | File List (custom text) |
|
||
|-------|-------------|-----------|-------------------------|
|
||
| From | ✓ | ✓ | — |
|
||
| To | ✓ | ✓ | — |
|
||
| Purpose | ✓ | ✓ | — |
|
||
| Response Due | — | ✓ | — |
|
||
| Subject | ✓ | ✓ | ✓ |
|
||
| Remarks | ✓ | ✓ | ✓ |
|
||
|
||
**Usage**:
|
||
- **Transmittal** — General document transmittals (no response required)
|
||
- **Submittal** — Submittals requiring response (shop drawings, product data, samples)
|
||
- **Custom text** — Type any text (e.g., "Contract Attachment 1", "Document Register") for file lists without workflow fields
|
||
|
||
**Primary actions** (toolbar buttons):
|
||
- **Select Directory** / **Scan Directory** — Scan filesystem to populate files table
|
||
- **Verify Directory** — Re-hash files and compare against stored hashes
|
||
- **Publish** — Opens publish modal (Draft / Unsigned / Signed)
|
||
|
||
**Menu actions** (☰ dropdown):
|
||
- **Scan Directory** / **Verify Directory** — Same as toolbar buttons
|
||
- **Publish** / **Save Draft** / **Create Folder** — Publishing and folder management
|
||
- **Paste New Rows** / **Paste Append Rows** — Paste file lists from clipboard
|
||
- **Copy Table** — Copy file table to clipboard as tab-separated text
|
||
- **Remove Files** — Clear file list, keep header and remarks
|
||
- **Import HTML** — Load a saved draft or published transmittal
|
||
- **Copy JSON** / **Paste JSON** — JSON clipboard operations
|
||
- **Reset** — Clear all form data and files (start over)
|
||
- **Create Index** — Generate archive redirect index in selected directory
|
||
|
||
**Header bar actions** (always available):
|
||
- **View/Edit toggle** — Switch between edit and read-only mode
|
||
- **Load JSON** — Import a previously saved document
|
||
- **Download Data** — Export JSON for signature verification or system integration
|
||
- **Help** — View this documentation
|
||
|
||
**UI behavior**: All form fields and table cells are editable. Filter inputs remain active for searching.
|
||
|
||
### View Mode
|
||
|
||
**When active**: Toggle from Edit mode or when opening a published transmittal.
|
||
|
||
**Available actions**:
|
||
- **View/Edit** — Switch to Edit mode (allows modifications)
|
||
- **Download Data** — Export JSON for signature verification or system integration
|
||
- **Help** — View this documentation
|
||
|
||
**UI behavior**: Form fields and table cells are read-only. Filter inputs remain active for searching. All editing controls hidden (Select Directory, Publish, Reset).
|
||
|
||
### Published State
|
||
|
||
**When active**: Automatically detected when transmittal contains a valid digest.
|
||
|
||
**Additional display**: Signature verification status shown above files table with public key fingerprint(s).
|
||
|
||
**Behavior**: Defaults to View mode on load. Can toggle to Edit mode, but signature status indicates the transmittal has been published.
|
||
|
||
### Mode Transitions
|
||
|
||
```
|
||
New Transmittal → Edit Mode
|
||
↓
|
||
Save Draft → Edit Mode (unsigned)
|
||
↓
|
||
Sign & Publish → Published State (View Mode)
|
||
↓
|
||
Toggle Edit → Published State (Edit Mode)
|
||
```
|
||
|
||
|
||
---
|
||
|
||
Authoring notes:
|
||
|
||
- UI copy uses “Tracking Number” consistently. The underlying key remains `trackingNumber`.
|
||
- This README documents only the transmittal folder assets and does not change any code outside this directory.
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|