ZDDC/website/releases/transmittal_v0.0.2.html
ZDDC 4d6e497510 release: archive/transmittal/classifier/mdedit/landing v0.0.2 stable
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>
2026-04-29 13:16:32 -05:00

10999 lines
410 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

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

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

<!--
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">&#x25BE;</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&#10;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&#10;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">&times;</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">&times;</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 &amp; 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">&times;</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">&times;</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 &mdash; a value computed from the file&rsquo;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> &mdash; tracking number, date, recipient, project, etc.</li>
<li>Optionally <em>Save Draft</em> to create a reusable template.</li>
<li><strong>Add files</strong> &mdash; 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> &mdash; 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> &mdash; 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> &mdash; A human-readable record of everything sent. Works without JavaScript (e.g.&nbsp;on a SharePoint site). No cryptographic protection.</li>
<li><strong>Digest check</strong> &mdash; 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> &mdash; ECDSA signatures bind the digest to a signer&rsquo;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> &mdash; A tampered HTML file could ship modified JavaScript that always says &ldquo;Verified.&rdquo; To rule this out, the recipient opens the sender&rsquo;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&rsquo;s own code. Use this level for anything that matters.</li>
</ol>
<p class="text-sm text-gray-500">Level&nbsp;4 assumes the recipient&rsquo;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 &ldquo;Received By&rdquo; in the signature list &mdash; 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.&nbsp;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 &amp; 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&nbsp;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 &mdash; 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 &mdash; 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
};
util.escapeHtmlAttribute = function escapeHtmlAttribute(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;');
};
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, '&amp;').replace(/"/g, '&quot;') + '"';
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function escapeAttr(str) {
return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
}
// 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 (Chromiumbased 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>