ZDDC/zddc/internal/apps/embedded/classifier.html
ZDDC d874643af5
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 3s
Build + deploy releases / build-and-deploy (push) Successful in 8s
Build + deploy releases / notify-chart-prod (push) Successful in 1s
release: v0.0.14 lockstep
2026-05-03 20:40:02 -05:00

7572 lines
245 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Classifier</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
<style>
/* ==========================================================================
ZDDC Shared Base — single source of truth for tokens and primitives
Included first by every tool's build.sh via ../shared/base.css
========================================================================== */
/* ── CSS custom properties ────────────────────────────────────────────────── */
:root {
/* Brand / accent (matches zddc.varasys.io website --accent) */
--primary: #2a5a8a;
--primary-hover: #1d4060;
--primary-active: #163352;
--primary-light: #e8f0f7;
/* Semantic colours */
--success: #28a745;
--warning: #d97706;
--danger: #dc3545;
--info: #17a2b8;
/* Backgrounds */
--bg: #ffffff;
--bg-secondary: #f8f9fa;
--bg-hover: #f0f4f8;
--bg-selected: var(--primary-light);
/* Text */
--text: #212529;
--text-muted: #6c757d;
--text-light: #ffffff;
/* Borders */
--border: #dee2e6;
--border-dark: #adb5bd;
/* Shape */
--radius: 4px;
/* Typography */
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
}
/* ── Dark mode tokens ─────────────────────────────────────────────────────── */
/* Applied via: OS preference (auto) or [data-theme="dark"] on <html> */
/* The [data-theme="light"] selector locks light mode regardless of OS pref. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
}
/* Manual dark override — wins over media query */
[data-theme="dark"] {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
/* ── Reset ────────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Base document ────────────────────────────────────────────────────────── */
html, body {
height: 100%;
font-family: var(--font);
font-size: 16px;
line-height: 1.5;
color: var(--text);
background-color: var(--bg-secondary);
}
/* ── Typography ───────────────────────────────────────────────────────────── */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.2;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ── Utility ──────────────────────────────────────────────────────────────── */
.hidden {
display: none !important;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Scrollbars (webkit) ──────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 7px;
height: 7px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
/* ── Button primitive ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.85rem;
font-family: var(--font);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.4;
text-align: center;
text-decoration: none;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
border: 1px solid transparent;
border-radius: var(--radius);
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, color 0.15s;
background: var(--bg-secondary);
color: var(--text);
}
.btn:disabled,
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.btn:not(:disabled):hover {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
.btn:not(:disabled):active {
box-shadow: none;
}
/* Variants */
.btn-primary {
background: var(--primary);
color: var(--text-light);
border-color: var(--primary);
}
.btn-primary:not(:disabled):hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--text-light);
}
.btn-primary:not(:disabled):active {
background: var(--primary-active);
border-color: var(--primary-active);
}
.btn-secondary {
background: var(--bg);
color: var(--text);
border-color: var(--border);
}
.btn-secondary:not(:disabled):hover {
background: var(--bg-secondary);
}
.btn-success {
background: var(--success);
color: var(--text-light);
border-color: var(--success);
}
.btn-danger {
background: var(--danger);
color: var(--text-light);
border-color: var(--danger);
}
/* Sizes */
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.btn-lg {
padding: 0.6rem 1.4rem;
font-size: 1rem;
}
.btn-link {
background: transparent;
border-color: transparent;
color: var(--primary);
padding-left: 0;
padding-right: 0;
}
.btn-link:not(:disabled):hover {
text-decoration: underline;
box-shadow: none;
}
/* ── App header chrome ────────────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
/* Tool name inside the header */
.app-header__title {
font-size: 17px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.01em;
white-space: nowrap;
}
/* Brand logo — sits left of the title in every tool's app-header.
Self-contained: the SVG provides its own dark blue rounded background,
so no extra wrapper styling is needed. */
.app-header__logo {
width: 26px;
height: 26px;
flex-shrink: 0;
display: block;
}
/* ── Build timestamp ──────────────────────────────────────────────────────── */
.build-timestamp {
font-size: 0.55rem;
color: var(--text-muted);
opacity: 0.7;
font-weight: 300;
white-space: nowrap;
padding-top: 0.15rem;
}
/* Title + timestamp stacked vertically on the left side of the header */
.header-title-group {
display: flex;
flex-direction: column;
gap: 0;
line-height: 1;
}
/* ── Icon buttons (help, theme, refresh) ─────────────────────────────────── */
/* Square, centered — overrides the asymmetric text-button padding/line-height */
#help-btn,
#theme-btn,
#refreshHeaderBtn {
width: 2rem;
height: 2rem;
padding: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
#theme-btn,
#help-btn {
font-size: 1rem;
}
/* ── Help panel (shared slide-out drawer) ─────────────────────────────────── */
/* Used by all four tools. Toggle open/close via shared/help.js. */
.help-panel {
position: fixed;
top: 0;
right: 0;
width: min(420px, 85vw);
height: 100vh;
z-index: 1000;
background: var(--bg);
border-left: 1px solid var(--border);
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
}
.help-panel:not([hidden]) {
transform: translateX(0);
}
.help-panel[hidden] {
display: flex;
transform: translateX(100%);
pointer-events: none;
}
.help-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg);
}
.help-panel__title {
font-size: 1rem;
font-weight: 700;
color: var(--text);
margin: 0;
}
.help-panel__close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.35rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.help-panel__close:hover {
color: var(--text);
background: var(--bg-secondary);
}
.help-panel__body {
flex: 1;
overflow-y: auto;
padding: 1rem 1rem 2rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.help-panel__body h3 {
font-size: 0.95rem;
font-weight: 700;
margin: 1.25rem 0 0.35rem;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 0.15rem;
}
.help-panel__body h3:first-child {
margin-top: 0;
}
.help-panel__body h4 {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 1.25rem 0 0.3rem;
padding-left: 0.5rem;
border-left: 3px solid var(--border-dark);
color: var(--text-muted);
}
.help-panel__body p {
margin: 0 0 0.5rem;
}
.help-panel__body ol,
.help-panel__body ul {
padding-left: 1.5rem;
margin: 0.3rem 0 0.5rem;
}
.help-panel__body li {
margin-bottom: 0.3rem;
}
.help-panel__body dl {
margin: 0.3rem 0;
}
.help-panel__body dt {
font-weight: 600;
color: var(--text);
}
.help-panel__body dd {
margin: 0 0 0.5rem 1rem;
color: var(--text-muted);
}
.help-panel__body code {
font-family: var(--font-mono);
font-size: 0.8em;
background: var(--bg-secondary);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.help-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: var(--radius);
vertical-align: middle;
letter-spacing: 0.02em;
}
.help-badge--draft {
color: #2563eb;
background: #eff6ff;
}
.help-badge--published {
color: #7c3aed;
background: #f5f3ff;
}
/* Shrink main content when help panel is open */
body.help-open .app-header {
margin-right: min(420px, 85vw);
}
/* ── Column filter inputs (shared across archive, classifier, transmittal) ─── */
.column-filter {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 0.25rem;
padding: 0.2rem 0.4rem;
font-size: 0.8rem;
font-family: var(--font);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
transition: border-color 0.15s;
}
.column-filter:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 1px rgba(42, 90, 138, 0.35);
}
.column-filter::placeholder {
color: var(--text-muted);
}
/* Classifier-specific base overrides
Reset, tokens, buttons, and font are provided by shared/base.css */
#app {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
/* Utility */
.text-muted { color: var(--text-muted); }
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-danger { color: var(--danger); }
/* Checkbox label */
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
font-size: 0.875rem;
}
.checkbox-label input[type="checkbox"] {
cursor: pointer;
}
/* ── Toast notifications (classifier-only) ───────────────────────────────── */
/* shared/base.css intentionally omits toast CSS; only classifier uses toasts. */
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
font-size: 0.875rem;
animation: zddc-toast-in 0.3s ease-out;
}
.toast-success { border-left: 4px solid var(--success); }
.toast-error { border-left: 4px solid var(--danger); }
.toast-info { border-left: 4px solid var(--info); }
.toast-warning { border-left: 4px solid var(--warning); }
.toast-fade {
animation: zddc-toast-out 0.3s ease-out forwards;
}
@keyframes zddc-toast-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes zddc-toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* Classifier layout — tokens from shared/base.css */
/* Empty State — positioned below the app header */
.empty-state {
position: absolute;
top: 50px; /* clear the header */
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
z-index: 10;
}
.empty-state-content {
text-align: center;
max-width: 500px;
padding: 2rem;
}
.empty-state-content h2 {
color: var(--text);
margin-bottom: 1rem;
}
.empty-state-content p {
margin-bottom: 1rem;
color: var(--text-muted);
}
.empty-state-content .note {
font-size: 0.85rem;
font-style: italic;
}
.welcome-list {
text-align: left;
margin: 0.5rem auto;
max-width: 400px;
}
.empty-state.drag-over {
background: var(--primary-light);
outline: 2px dashed var(--primary);
outline-offset: -4px;
}
/* Browser Warning */
.browser-warning {
background-color: rgba(217, 119, 6, 0.08);
border: 2px solid var(--warning);
border-radius: var(--radius);
padding: 1.5rem;
margin: 1.5rem 0;
text-align: left;
}
.browser-warning h3 {
color: var(--warning);
margin-top: 0;
}
.browser-warning ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
/* Main App */
.main-app {
display: flex;
flex-direction: column;
height: 100vh;
background-color: var(--bg);
position: relative;
}
/* Header — shared/base.css provides .app-header base */
.app-header {
padding: 0.5rem 1rem;
}
.header-left,
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-divider {
color: var(--border);
margin: 0 0.25rem;
}
/* Main Content */
.main-content {
display: flex;
flex: 1;
overflow: hidden;
}
/* Folder Tree Pane */
.folder-tree-pane {
width: 300px;
min-width: 150px;
display: flex;
flex-direction: column;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border);
flex-shrink: 0;
position: relative;
transition: width 0.2s ease, min-width 0.2s ease;
}
.folder-tree-pane.collapsed {
width: 40px !important;
min-width: 40px !important;
max-width: 40px !important;
overflow: hidden;
}
.folder-tree-pane.collapsed .pane-header-controls,
.folder-tree-pane.collapsed .folder-tree,
.folder-tree-pane.collapsed .pane-header h3 {
display: none;
}
.folder-tree-pane.collapsed .pane-header {
padding: 0.5rem;
justify-content: center;
}
.folder-tree-pane.collapsed .pane-header-title {
flex-direction: column;
}
.pane-header-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-tree-btn {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
/* Resize Handle */
.resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 5px;
cursor: col-resize;
background-color: transparent;
z-index: 10;
}
.resize-handle:hover {
background-color: var(--primary);
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background-color: var(--bg);
border-bottom: 1px solid var(--border);
}
.pane-header-left,
.pane-header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.pane-header h3 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.pane-header-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-end;
}
.folder-stats,
.file-stats {
display: flex;
gap: 1rem;
font-size: 12px;
color: var(--text-muted);
}
.folder-tree {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
/* Folder Item */
.folder-item {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
border-radius: var(--radius);
user-select: none;
transition: background-color 0.15s;
}
.folder-item:hover {
background-color: var(--bg-hover);
}
.folder-item.selected {
background-color: var(--bg-selected);
font-weight: 500;
}
.folder-item.folder-hover-highlight {
background-color: rgba(217, 119, 6, 0.12);
border-left: 3px solid var(--warning);
transition: background-color 0.2s, border-left 0.2s;
}
.folder-item.has-unsaved {
border-left: 3px solid var(--warning);
}
.folder-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 12px;
color: var(--text-muted);
}
.folder-icon {
margin-right: 0.5rem;
color: var(--text-muted);
}
.folder-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-count {
font-size: 11px;
color: var(--text-muted);
margin-left: 0.5rem;
}
.folder-children {
margin-left: 1.5rem;
}
/* Spreadsheet Pane */
.spreadsheet-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.spreadsheet-container {
flex: 1;
overflow: auto;
background-color: var(--bg);
}
/* ZIP Extract Button in Tree */
.zip-extract-btn {
margin-left: auto;
padding: 0.15rem 0.4rem;
font-size: 0.7rem;
opacity: 0;
transition: opacity 0.15s;
}
.folder-item:hover .zip-extract-btn {
opacity: 1;
}
.zip-extract-btn:disabled {
opacity: 0.5;
cursor: wait;
}
/* ZIP Extract All Button */
.zip-extract-all-btn {
margin-left: auto;
padding: 0.15rem 0.4rem;
font-size: 0.7rem;
opacity: 0;
transition: opacity 0.15s;
}
.folder-item:hover .zip-extract-all-btn {
opacity: 1;
}
.zip-extract-all-btn:disabled {
opacity: 0.5;
cursor: wait;
}
/**
* Spreadsheet Styles
* Table, cells, editing, and row states
*/
.spreadsheet {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
background-color: var(--bg);
}
/* Selected cells */
.selected-cell {
background-color: rgba(0, 123, 255, 0.2) !important;
outline: 1px solid var(--primary);
}
/* Auto-populated cells (gray text to indicate matches filename) */
.cell-editable.auto-populated {
color: var(--text-muted);
}
/* Changed fields (blue text to indicate value differs from original filename) */
.cell-editable.field-changed {
color: var(--primary);
font-weight: 500;
}
.spreadsheet thead {
position: sticky;
top: 0;
z-index: 10;
background-color: var(--bg-secondary);
}
.spreadsheet th {
padding: 0.75rem 0.5rem;
text-align: left;
font-weight: 600;
border-bottom: 2px solid var(--border);
background-color: var(--bg-secondary);
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spreadsheet th:hover:not(.col-row-num) {
background-color: var(--border);
}
/* Sort indicator */
.sort-indicator {
display: inline-block;
font-size: 0.75rem;
color: var(--primary);
margin-left: 0.25rem;
margin-right: 0.25rem;
font-weight: bold;
vertical-align: middle;
}
.spreadsheet td {
padding: 0.5rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
white-space: nowrap;
}
/* Column resizer */
.column-resizer {
position: absolute;
top: 0;
right: 0;
width: 5px;
height: 100%;
cursor: col-resize;
user-select: none;
z-index: 1;
}
.column-resizer:hover {
background-color: var(--primary);
}
/* Column Widths */
.col-row-num {
width: 50px;
text-align: center;
background-color: var(--bg-secondary);
font-weight: 600;
color: var(--text-muted);
user-select: none;
-webkit-user-select: none;
}
.col-original {
min-width: 250px;
}
.col-extension {
width: 60px;
text-align: center;
}
.col-new {
min-width: 250px;
}
.col-trackingNumber {
min-width: 200px;
width: 200px;
}
.col-revision {
width: 80px;
}
.col-status {
width: 100px;
}
.col-title {
min-width: 200px;
}
.col-sha256 {
min-width: 150px;
font-family: var(--font-mono);
font-size: 11px;
}
.col-actions {
width: 100px;
text-align: center;
}
/* Row States */
.spreadsheet tbody tr {
transition: background-color 0.15s;
}
.spreadsheet tbody tr:hover {
background-color: var(--bg-hover);
}
.spreadsheet tbody tr.modified {
border-left: 3px solid var(--warning);
}
.spreadsheet tbody tr.error {
border-left: 3px solid var(--danger);
background-color: rgba(220, 53, 69, 0.08);
}
.spreadsheet tbody tr.saving {
opacity: 0.6;
pointer-events: none;
}
/* Cell Content */
.cell-content {
display: block;
width: 100%;
}
.cell-link {
color: var(--primary);
text-decoration: none;
}
.cell-link:hover {
text-decoration: underline;
}
.cell-extension {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
}
.cell-computed {
font-style: italic;
color: var(--text-muted);
}
/* Editable Cells */
.cell-editable {
cursor: text;
position: relative;
}
.cell-editable:hover {
background-color: var(--bg-hover);
}
.cell-content[contenteditable="true"] {
outline: 2px solid var(--primary);
outline-offset: 0;
background-color: var(--bg);
min-height: 1.5em;
}
.cell-content[contenteditable="true"]:focus {
outline: 2px solid var(--primary);
outline-offset: 0;
}
.cell-content.editing {
white-space: pre-wrap;
word-break: break-word;
}
/* Computed cells */
.cell-editable.computed {
font-style: italic;
color: var(--text-muted);
}
.cell-editable.computed:hover {
font-style: normal;
color: var(--text);
}
/* Validation states */
.validation-error {
background-color: rgba(220, 53, 69, 0.1);
border-left: 3px solid var(--danger);
}
.validation-warning {
background-color: rgba(255, 193, 7, 0.1);
border-left: 3px solid #ffc107;
}
/* Inline Actions */
.inline-actions {
position: absolute;
right: 0.25rem;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 0.25rem;
background-color: var(--bg);
padding: 0.125rem;
border-radius: 3px;
}
.cell-editable {
position: relative;
padding-right: 3.5rem; /* Space for buttons */
}
.btn-inline {
border: none;
background: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 14px;
padding: 0.25rem 0.5rem;
border-radius: 3px;
transition: all 0.15s;
font-weight: bold;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.btn-save {
color: var(--success);
}
.btn-save:hover {
background-color: var(--success);
color: white;
}
.btn-cancel {
color: var(--danger);
}
.btn-cancel:hover {
background-color: var(--danger);
color: white;
}
/* Formula Preview */
.formula-preview {
position: absolute;
bottom: 100%;
left: 0;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.5rem;
font-size: 12px;
min-width: 200px;
z-index: 100;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.formula-preview-label {
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.formula-preview-value {
color: var(--text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.formula-preview-value.valid {
color: var(--success);
}
.formula-preview-value.invalid {
color: var(--danger);
}
.preview-check {
color: var(--success);
font-size: 16px;
cursor: pointer;
padding: 0.25rem;
border-radius: 3px;
transition: background-color 0.15s;
}
.preview-check:hover {
background-color: rgba(40, 167, 69, 0.1);
}
.preview-error {
color: var(--danger);
font-size: 16px;
}
.formula-preview-errors {
margin-top: 0.25rem;
font-size: 11px;
color: var(--danger);
white-space: normal;
}
/* Validation States */
.cell-warning {
background-color: rgba(255, 193, 7, 0.08);
border-left: 3px solid var(--warning);
}
.cell-error {
background-color: rgba(220, 53, 69, 0.08);
border-left: 3px solid var(--danger);
}
.validation-icon {
display: inline-block;
margin-left: 0.25rem;
cursor: help;
}
/* SHA256 Column */
.sha256-hash {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
}
.sha256-calculating {
font-style: italic;
color: var(--text-muted);
}
/* Action Buttons */
.row-actions {
display: flex;
gap: 0.25rem;
justify-content: center;
}
.btn-icon {
width: 28px;
height: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 4px;
background-color: var(--bg);
cursor: pointer;
transition: all 0.15s;
}
.btn-icon:hover:not(:disabled) {
background-color: var(--bg-hover);
transform: scale(1.1);
}
.btn-icon:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-save {
color: var(--success);
}
.btn-cancel {
color: var(--danger);
}
/* Empty State */
.empty-state {
padding: 3rem;
text-align: center;
color: var(--text-muted);
}
.empty-state h3 {
margin-bottom: 0.5rem;
}
/* Spreadsheet Empty State */
.spreadsheet-empty {
text-align: center;
color: var(--text-muted);
padding: 1.5rem;
}
/* Selection Highlight */
.cell-selected {
outline: 2px solid var(--primary);
outline-offset: -2px;
z-index: 1;
}
/* Scrollbar Styling */
.spreadsheet-container::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.spreadsheet-container::-webkit-scrollbar-track {
background-color: var(--bg-secondary);
}
.spreadsheet-container::-webkit-scrollbar-thumb {
background-color: var(--border-dark);
border-radius: 6px;
}
.spreadsheet-container::-webkit-scrollbar-thumb:hover {
background-color: var(--text-muted);
}
/* Preview button active state */
#togglePreviewBtn.preview-active {
background-color: var(--primary);
color: white;
border-color: var(--primary);
}
</style>
</head>
<body>
<div id="app">
<!-- Main Application -->
<div id="mainApp" class="main-app">
<!-- Header -->
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp">v0.0.14</span>
</div>
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div>
</header>
<!-- Main Content -->
<div class="main-content">
<!-- Folder Tree -->
<aside class="folder-tree-pane" id="folderTreePane">
<div class="pane-header">
<div class="pane-header-title">
<button class="btn btn-sm collapse-tree-btn" id="collapseTreeBtn" title="Collapse folder tree"></button>
<h3>Folder Tree</h3>
</div>
<div class="pane-header-controls">
<label class="checkbox-label" title="Auto-scroll folder tree when hovering files">
<input type="checkbox" id="autoScrollCheckbox" checked>
Auto-scroll
</label>
<label class="checkbox-label">
<input type="checkbox" id="hideCompliantCheckbox">
Hide Compliant
</label>
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
</div>
</div>
<div id="folderTree" class="folder-tree">
<!-- Dynamically populated -->
</div>
<div class="resize-handle" id="treeResizeHandle"></div>
</aside>
<!-- Spreadsheet Table -->
<main class="spreadsheet-pane">
<div class="pane-header">
<div class="pane-header-left">
<h3>Files</h3>
<div class="file-stats">
<span id="totalFiles">0 files</span>
<span id="modifiedFiles">0 modified</span>
<span id="errorFiles" class="hidden">0 errors</span>
</div>
</div>
<div class="pane-header-right">
<button id="saveAllBtn" class="btn btn-success btn-sm" disabled>Save All</button>
<button id="cancelAllBtn" class="btn btn-secondary btn-sm" disabled>Cancel All</button>
<span class="header-divider">|</span>
<label class="checkbox-label">
<input type="checkbox" id="sha256Checkbox">
SHA256
</label>
<button id="exportHashesBtn" class="btn btn-secondary btn-sm" disabled title="Export SHA256 hashes in sha256sum format">💾 Export Hashes</button>
<span class="header-divider">|</span>
<button id="togglePreviewBtn" class="btn btn-secondary btn-sm" title="Toggle file preview panel">👁 Preview</button>
</div>
</div>
<div class="spreadsheet-container">
<table id="spreadsheet" class="spreadsheet">
<thead>
<tr>
<th class="col-row-num">#</th>
<th class="col-original">Original Filename
<input type="text" class="column-filter" data-filter-field="original" placeholder="filter…" spellcheck="false" aria-label="Filter by original filename">
</th>
<th class="col-extension">Ext
<input type="text" class="column-filter" data-filter-field="extension" placeholder="filter…" spellcheck="false" aria-label="Filter by extension">
</th>
<th class="col-new">New Filename
<input type="text" class="column-filter" data-filter-field="newFilename" placeholder="filter…" spellcheck="false" aria-label="Filter by new filename">
</th>
<th class="col-trackingNumber">Tracking
<input type="text" class="column-filter" data-filter-field="trackingNumber" placeholder="filter…" spellcheck="false" aria-label="Filter by tracking number">
</th>
<th class="col-revision">Rev
<input type="text" class="column-filter" data-filter-field="revision" placeholder="filter…" spellcheck="false" aria-label="Filter by revision">
</th>
<th class="col-status">Status
<input type="text" class="column-filter" data-filter-field="status" placeholder="filter…" spellcheck="false" aria-label="Filter by status">
</th>
<th class="col-title">Title
<input type="text" class="column-filter" data-filter-field="title" placeholder="filter…" spellcheck="false" aria-label="Filter by title">
</th>
<th class="col-sha256 hidden" id="sha256Column">SHA256
<input type="text" class="column-filter" data-filter-field="sha256" placeholder="filter…" spellcheck="false" aria-label="Filter by SHA256">
</th>
</tr>
</thead>
<tbody id="spreadsheetBody">
<!-- Dynamically populated -->
</tbody>
</table>
</div>
</main>
</div>
<!-- Empty State — shown until a directory is selected -->
<div id="welcomeScreen" class="empty-state">
<div class="empty-state-content">
<h2>ZDDC Classifier</h2>
<p>Rename a folder of files to ZDDC format using a spreadsheet interface.</p>
<p>Open a directory, fill in tracking number, revision, status, and title for each file, then save — the files are renamed on disk.</p>
<!-- Browser Compatibility Warning -->
<div id="browserWarning" class="browser-warning hidden">
<h3>⚠️ Browser Not Supported</h3>
<p>This application requires the File System Access API, available only in Chromium-based browsers (Chrome, Edge, Brave, Opera).</p>
</div>
<ul class="welcome-list">
<li>Files already named to ZDDC format are parsed automatically</li>
<li>Edit cells directly, or copy columns to and from Excel</li>
<li>Real-time validation highlights non-compliant names</li>
<li>Rename one file or all modified files at once</li>
</ul>
<p>Click <strong>Select Directory</strong> to begin.</p>
<p class="note">This application works entirely in your browser. No data is transmitted to any server.</p>
</div>
</div>
</div>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Classifier</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 the Classifier?</h3>
<p>The Classifier is a spreadsheet-based tool for renaming files to ZDDC naming conventions. It reads a folder of files and presents them in an editable grid where you can set tracking number, revision, status, and title — then saves the renamed files back to disk.</p>
<h3>Getting Started</h3>
<ol>
<li>Click <strong>Select Directory</strong> to open a folder containing files to rename.</li>
<li>The folder tree on the left shows all sub-folders. Click a folder to load its files.</li>
<li>Edit cells in the spreadsheet to set the new filename components.</li>
<li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li>
</ol>
<h3>Folder Tree</h3>
<dl>
<dt>Multi-select</dt>
<dd>Hold <kbd>Ctrl</kbd> and click to select multiple folders. Hold <kbd>Shift</kbd> to select a range. Files from all selected folders are shown together.</dd>
<dt>Hide Compliant</dt>
<dd>Hides folders where all files already have valid ZDDC names, letting you focus on work remaining.</dd>
<dt>Auto-scroll</dt>
<dd>When enabled, the folder tree scrolls to highlight the folder containing the row you are editing.</dd>
</dl>
<h3>Spreadsheet Editing</h3>
<dl>
<dt>Direct cell editing</dt>
<dd>Click any cell in the New Filename, Tracking, Rev, Status, or Title columns to edit it. Press <kbd>Enter</kbd> to confirm, <kbd>Escape</kbd> to cancel.</dd>
<dt>RC References</dt>
<dd>Type a formula like <code>=R[-1]C</code> to copy the value from the cell one row above in the same column — similar to Excel relative references.</dd>
<dt>Regex capture groups</dt>
<dd>Type a formula like <code>=RE(RC[-3], "(\w+)-(\d+)", "$1")</code> to extract a pattern from another cell using a regular expression.</dd>
<dt>Validation</dt>
<dd>Cells are validated automatically. Invalid values are highlighted in red. The New Filename column shows the composed result.</dd>
<dt>Column Filters</dt>
<dd>Each column header has a filter input. Supported syntax:</dd>
</dl>
<dl>
<dt><code>term</code></dt>
<dd>Contains "term" (case-insensitive)</dd>
<dt><code>!term</code></dt>
<dd>Does not contain "term"</dd>
<dt><code>^term</code></dt>
<dd>Starts with "term"</dd>
<dt><code>term$</code></dt>
<dd>Ends with "term"</dd>
<dt><code>a b</code></dt>
<dd>Matches both (AND)</dd>
<dt><code>a | b</code></dt>
<dd>Matches either (OR)</dd>
<dt><code>^IFA | ^IFB</code></dt>
<dd>Starts with IFA or IFB</dd>
<dt><code>!^~</code></dt>
<dd>Does not start with ~ (excludes drafts)</dd>
<dt><code>el.*spc</code></dt>
<dd>Regex: contains "el" followed by "spc" (use <code>.</code> for any char, <code>.*</code> for any sequence)</dd>
<dt><code>[ei]fa</code></dt>
<dd>Regex character class: matches "efa" or "ifa"</dd>
</dl>
<h3>Saving Files</h3>
<dl>
<dt>Save All</dt>
<dd>Renames all modified files in one operation. Confirms before proceeding.</dd>
<dt>Cancel All</dt>
<dd>Reverts all unsaved edits back to the original filenames.</dd>
<dt>SHA256</dt>
<dd>Enable to compute a cryptographic hash of each file. Use <strong>Export Hashes</strong> to save a <code>sha256sum</code>-compatible file.</dd>
</dl>
<h3>ZDDC Filename Format</h3>
<p>The required format is:</p>
<p><code>TRACKINGNUMBER_REVISION (STATUS) - Title.ext</code></p>
<p>Example: <code>123456-EL-SPC-2623_A (IFR) - Electrical Specification.pdf</code></p>
<p>Valid statuses: IFA, IFB, IFC, IFD, IFI, IFP, IFR, IFU, REC, RSA, RSB, RSC, RSD, RSI, ---</p>
</div>
</aside>
</div>
<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();
}
}());
/**
* ZDDC — shared preview helpers
*
* Cross-tool helpers for previewing file types that need a decoder:
* - TIFF (UTIF.js) — multi-page, browser-PDF-viewer-style toolbar
* - ZIP listing (JSZip) — sortable file-list view
*
* Renderers operate on any document (parent window or popup window), so the
* same code works for tools whose preview opens in a popup (classifier,
* archive, transmittal) and tools that render inline (mdedit).
*
* Public API on window.zddc.preview:
* loadLibrary(url) → Promise<void>
* renderTiff(doc, container, arrayBuffer, opts) → Promise<void>
* renderZipListing(doc, container, arrayBuffer, opts) → Promise<void>
* TIFF_EXTENSIONS, IMAGE_EXTENSIONS, TEXT_EXTENSIONS, OFFICE_EXTENSIONS
* isTiff(ext), isImage(ext), isText(ext), isZip(ext), isOffice(ext)
*
* Each tool keeps its own dispatcher; this lib only owns the heavy renderers.
*/
(function (root) {
'use strict';
var TIFF_EXTENSIONS = ['tif', 'tiff'];
var IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'];
var TEXT_EXTENSIONS = [
'txt', 'md', 'markdown', 'json', 'xml', 'csv', 'tsv', 'log',
'html', 'htm', 'css', 'js', 'mjs', 'ts', 'tsx', 'jsx',
'py', 'rb', 'sh', 'bash', 'zsh', 'bat', 'ps1',
'yaml', 'yml', 'ini', 'cfg', 'conf', 'toml',
'c', 'cc', 'cpp', 'h', 'hpp', 'go', 'rs', 'java', 'kt',
'sql', 'env'
];
var OFFICE_EXTENSIONS = ['docx', 'xlsx', 'xls'];
function lowerExt(ext) { return (ext || '').toLowerCase(); }
function isTiff(ext) { return TIFF_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
function isImage(ext) { return IMAGE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
function isText(ext) { return TEXT_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
function isZip(ext) { return lowerExt(ext) === 'zip'; }
function isOffice(ext) { return OFFICE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
// ── CDN library loader (parent window cache) ─────────────────────────────
var _libCache = new Map();
function loadLibrary(url) {
if (_libCache.has(url)) return _libCache.get(url);
var p = new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = url;
s.onload = function () { resolve(); };
s.onerror = function () { reject(new Error('Failed to load: ' + url)); };
document.head.appendChild(s);
});
_libCache.set(url, p);
return p;
}
// ── Style injection (idempotent per-document) ────────────────────────────
function injectStyles(doc, id, css) {
if (doc.getElementById(id)) return;
var style = doc.createElement('style');
style.id = id;
style.textContent = css;
doc.head.appendChild(style);
}
// ── Helpers ──────────────────────────────────────────────────────────────
function formatSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function formatDate(d) {
if (!d) return '';
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── TIFF renderer ────────────────────────────────────────────────────────
var TIFF_CSS =
'.tiff-toolbar{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;' +
'background:#f5f5f5;border-bottom:1px solid #ddd;flex-wrap:wrap;font-size:.85rem;}' +
'.tiff-toolbar .tiff-btn{padding:.25rem .55rem;border:1px solid #ccc;border-radius:3px;' +
'background:#fff;cursor:pointer;font-size:.85rem;line-height:1;min-width:1.8rem;}' +
'.tiff-toolbar .tiff-btn:hover:not(:disabled){background:#e8e8e8;}' +
'.tiff-toolbar .tiff-btn:disabled{opacity:.4;cursor:default;}' +
'.tiff-toolbar .tiff-page-info{display:inline-flex;align-items:center;gap:.3rem;}' +
'.tiff-toolbar .tiff-page-input{width:3.2rem;padding:.2rem .3rem;border:1px solid #ccc;' +
'border-radius:3px;text-align:center;font-size:.85rem;}' +
'.tiff-toolbar .tiff-zoom-select{padding:.2rem .3rem;border:1px solid #ccc;border-radius:3px;' +
'background:#fff;font-size:.85rem;}' +
'.tiff-toolbar .tiff-spacer{flex:1;}' +
'.tiff-viewport{flex:1;overflow:auto;background:#525659;display:flex;align-items:flex-start;' +
'justify-content:center;padding:1rem;}' +
'.tiff-canvas{background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.4);display:block;' +
'image-rendering:auto;}' +
'.tiff-error{flex:1;display:flex;align-items:center;justify-content:center;color:#900;' +
'padding:2rem;text-align:center;}';
function renderTiff(doc, container, arrayBuffer, opts) {
opts = opts || {};
injectStyles(doc, 'zddc-tiff-styles', TIFF_CSS);
return loadLibrary('https://cdn.jsdelivr.net/npm/utif@3.1.0/UTIF.js').then(function () {
var ifds;
try {
ifds = window.UTIF.decode(arrayBuffer);
} catch (e) {
container.innerHTML = '<div class="tiff-error">Failed to parse TIFF: '
+ escapeHtml(e.message || e) + '</div>';
return;
}
if (!ifds || !ifds.length) {
container.innerHTML = '<div class="tiff-error">No images found in TIFF.</div>';
return;
}
// Reset container to a flex column
container.innerHTML = '';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.minHeight = '0';
container.style.height = '100%';
container.style.overflow = 'hidden';
// Toolbar
var toolbar = doc.createElement('div');
toolbar.className = 'tiff-toolbar';
var btnPrev = doc.createElement('button');
btnPrev.className = 'tiff-btn'; btnPrev.type = 'button';
btnPrev.title = 'Previous page'; btnPrev.textContent = '◀';
var pageInfo = doc.createElement('span');
pageInfo.className = 'tiff-page-info';
var pageInput = doc.createElement('input');
pageInput.type = 'number'; pageInput.min = '1'; pageInput.value = '1';
pageInput.className = 'tiff-page-input';
var pageOf = doc.createElement('span');
pageOf.textContent = ' of ' + ifds.length;
pageInfo.appendChild(doc.createTextNode('Page '));
pageInfo.appendChild(pageInput);
pageInfo.appendChild(pageOf);
var btnNext = doc.createElement('button');
btnNext.className = 'tiff-btn'; btnNext.type = 'button';
btnNext.title = 'Next page'; btnNext.textContent = '▶';
var spacer = doc.createElement('span');
spacer.className = 'tiff-spacer';
var btnZoomOut = doc.createElement('button');
btnZoomOut.className = 'tiff-btn'; btnZoomOut.type = 'button';
btnZoomOut.title = 'Zoom out'; btnZoomOut.textContent = '';
var zoomSelect = doc.createElement('select');
zoomSelect.className = 'tiff-zoom-select';
var zoomOptions = [
['fit-width', 'Fit width'],
['fit-page', 'Fit page'],
['0.5', '50%'],
['0.75', '75%'],
['1', '100%'],
['1.25', '125%'],
['1.5', '150%'],
['2', '200%'],
['3', '300%'],
['4', '400%']
];
zoomOptions.forEach(function (z) {
var o = doc.createElement('option');
o.value = z[0]; o.textContent = z[1];
zoomSelect.appendChild(o);
});
zoomSelect.value = 'fit-width';
var btnZoomIn = doc.createElement('button');
btnZoomIn.className = 'tiff-btn'; btnZoomIn.type = 'button';
btnZoomIn.title = 'Zoom in'; btnZoomIn.textContent = '+';
toolbar.appendChild(btnPrev);
toolbar.appendChild(pageInfo);
toolbar.appendChild(btnNext);
toolbar.appendChild(spacer);
toolbar.appendChild(btnZoomOut);
toolbar.appendChild(zoomSelect);
toolbar.appendChild(btnZoomIn);
// Viewport with canvas
var viewport = doc.createElement('div');
viewport.className = 'tiff-viewport';
var canvas = doc.createElement('canvas');
canvas.className = 'tiff-canvas';
viewport.appendChild(canvas);
container.appendChild(toolbar);
container.appendChild(viewport);
// Render state
var currentPage = 0;
var zoom = 1;
var fitMode = 'width'; // 'width' | 'page' | null
var decoded = new Array(ifds.length);
function decodePage(i) {
if (decoded[i]) return decoded[i];
var ifd = ifds[i];
window.UTIF.decodeImage(arrayBuffer, ifd);
var rgba = window.UTIF.toRGBA8(ifd);
decoded[i] = { rgba: rgba, w: ifd.width, h: ifd.height };
return decoded[i];
}
function applyZoom() {
var page = decoded[currentPage];
if (!page) return;
var availW = viewport.clientWidth - 32; // padding
var availH = viewport.clientHeight - 32;
var scale;
if (fitMode === 'width') {
scale = availW / page.w;
} else if (fitMode === 'page') {
scale = Math.min(availW / page.w, availH / page.h);
} else {
scale = zoom;
}
if (!isFinite(scale) || scale <= 0) scale = 1;
canvas.style.width = (page.w * scale) + 'px';
canvas.style.height = (page.h * scale) + 'px';
}
function renderPage() {
var page;
try {
page = decodePage(currentPage);
} catch (e) {
container.innerHTML = '<div class="tiff-error">Failed to decode page '
+ (currentPage + 1) + ': ' + escapeHtml(e.message || e) + '</div>';
return;
}
canvas.width = page.w;
canvas.height = page.h;
var ctx = canvas.getContext('2d');
var imgData = ctx.createImageData(page.w, page.h);
imgData.data.set(page.rgba);
ctx.putImageData(imgData, 0, 0);
applyZoom();
pageInput.value = String(currentPage + 1);
btnPrev.disabled = currentPage <= 0;
btnNext.disabled = currentPage >= ifds.length - 1;
}
function setZoomFromSelect() {
var v = zoomSelect.value;
if (v === 'fit-width') { fitMode = 'width'; }
else if (v === 'fit-page') { fitMode = 'page'; }
else { fitMode = null; zoom = parseFloat(v) || 1; }
applyZoom();
}
function nudgeZoom(factor) {
if (fitMode) {
// capture current effective scale before leaving fit mode
var page = decoded[currentPage];
if (page) {
var availW = viewport.clientWidth - 32;
var availH = viewport.clientHeight - 32;
zoom = fitMode === 'width'
? availW / page.w
: Math.min(availW / page.w, availH / page.h);
} else {
zoom = 1;
}
fitMode = null;
}
zoom = Math.max(0.1, Math.min(8, zoom * factor));
// Match select option if any are close, else show as percent
var matched = false;
for (var i = 0; i < zoomSelect.options.length; i++) {
var ov = zoomSelect.options[i].value;
if (ov !== 'fit-width' && ov !== 'fit-page' && Math.abs(parseFloat(ov) - zoom) < 0.001) {
zoomSelect.value = ov; matched = true; break;
}
}
if (!matched) {
// Nearest standard step
var best = '1', bestDiff = Infinity;
for (var j = 0; j < zoomSelect.options.length; j++) {
var v2 = zoomSelect.options[j].value;
if (v2 === 'fit-width' || v2 === 'fit-page') continue;
var diff = Math.abs(parseFloat(v2) - zoom);
if (diff < bestDiff) { bestDiff = diff; best = v2; }
}
zoom = parseFloat(best);
zoomSelect.value = best;
}
applyZoom();
}
btnPrev.addEventListener('click', function () {
if (currentPage > 0) { currentPage--; renderPage(); }
});
btnNext.addEventListener('click', function () {
if (currentPage < ifds.length - 1) { currentPage++; renderPage(); }
});
pageInput.addEventListener('change', function () {
var n = parseInt(pageInput.value, 10);
if (!isNaN(n) && n >= 1 && n <= ifds.length) {
currentPage = n - 1;
renderPage();
} else {
pageInput.value = String(currentPage + 1);
}
});
zoomSelect.addEventListener('change', setZoomFromSelect);
btnZoomIn.addEventListener('click', function () { nudgeZoom(1.25); });
btnZoomOut.addEventListener('click', function () { nudgeZoom(1 / 1.25); });
// Keyboard nav (only when toolbar/viewport in focus path)
container.tabIndex = 0;
container.addEventListener('keydown', function (e) {
if (e.target === pageInput) return;
if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
if (currentPage > 0) { currentPage--; renderPage(); e.preventDefault(); }
} else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
if (currentPage < ifds.length - 1) { currentPage++; renderPage(); e.preventDefault(); }
}
});
// Re-fit on viewport resize
if (typeof (doc.defaultView && doc.defaultView.ResizeObserver) === 'function') {
var ro = new doc.defaultView.ResizeObserver(function () { applyZoom(); });
ro.observe(viewport);
} else if (doc.defaultView) {
doc.defaultView.addEventListener('resize', function () { applyZoom(); });
}
renderPage();
});
}
// ── ZIP listing renderer ─────────────────────────────────────────────────
var ZIP_CSS =
'.zip-header{padding:.4rem .8rem;background:#f5f5f5;border-bottom:1px solid #ddd;' +
'font-size:.85rem;color:#444;}' +
'.zip-table-wrap{flex:1;overflow:auto;}' +
'.zip-table{width:100%;border-collapse:collapse;font-size:.85rem;font-family:' +
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}' +
'.zip-table thead th{position:sticky;top:0;background:#f0f0f0;text-align:left;' +
'padding:.4rem .6rem;border-bottom:1px solid #ccc;cursor:pointer;user-select:none;' +
'font-weight:600;}' +
'.zip-table thead th:hover{background:#e6e6e6;}' +
'.zip-table thead th.zip-sort-asc::after{content:" ▲";font-size:.7rem;color:#888;}' +
'.zip-table thead th.zip-sort-desc::after{content:" ▼";font-size:.7rem;color:#888;}' +
'.zip-table tbody td{padding:.3rem .6rem;border-bottom:1px solid #eee;}' +
'.zip-table tbody tr:hover{background:#f6faff;}' +
'.zip-table .zip-folder{color:#888;}' +
'.zip-table .zip-name{color:#222;}' +
'.zip-table .zip-size,.zip-table .zip-date{font-variant-numeric:tabular-nums;' +
'white-space:nowrap;color:#555;}' +
'.zip-table .zip-col-size,.zip-table .zip-col-date{text-align:right;}' +
'.zip-empty{padding:2rem;text-align:center;color:#888;}';
function renderZipListing(doc, container, arrayBuffer, opts) {
opts = opts || {};
injectStyles(doc, 'zddc-zip-styles', ZIP_CSS);
return loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js').then(function () {
return window.JSZip.loadAsync(arrayBuffer);
}).then(function (zip) {
var entries = [];
zip.forEach(function (relativePath, zipEntry) {
if (zipEntry.dir) return;
var size = (zipEntry._data && zipEntry._data.uncompressedSize) || 0;
entries.push({
path: relativePath,
name: relativePath.split('/').pop(),
size: size,
modified: zipEntry.date instanceof Date ? zipEntry.date : null
});
});
container.innerHTML = '';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.minHeight = '0';
container.style.height = '100%';
container.style.overflow = 'hidden';
var totalSize = entries.reduce(function (s, e) { return s + e.size; }, 0);
var header = doc.createElement('div');
header.className = 'zip-header';
header.textContent = entries.length + ' file' + (entries.length === 1 ? '' : 's')
+ (totalSize ? ' · ' + formatSize(totalSize) + ' uncompressed' : '');
container.appendChild(header);
if (!entries.length) {
var empty = doc.createElement('div');
empty.className = 'zip-empty';
empty.textContent = '(empty archive)';
container.appendChild(empty);
return;
}
var wrap = doc.createElement('div');
wrap.className = 'zip-table-wrap';
var table = doc.createElement('table');
table.className = 'zip-table';
var thead = doc.createElement('thead');
var trh = doc.createElement('tr');
var cols = [
{ key: 'path', label: 'Name', cls: 'zip-col-name' },
{ key: 'size', label: 'Size', cls: 'zip-col-size' },
{ key: 'modified', label: 'Modified', cls: 'zip-col-date' }
];
cols.forEach(function (c) {
var th = doc.createElement('th');
th.className = c.cls;
th.dataset.key = c.key;
th.textContent = c.label;
trh.appendChild(th);
});
thead.appendChild(trh);
table.appendChild(thead);
var tbody = doc.createElement('tbody');
table.appendChild(tbody);
wrap.appendChild(table);
container.appendChild(wrap);
var sortKey = 'path';
var sortDir = 1;
function render() {
var sorted = entries.slice().sort(function (a, b) {
var av, bv;
if (sortKey === 'size') { av = a.size; bv = b.size; }
else if (sortKey === 'modified') {
av = a.modified ? a.modified.getTime() : 0;
bv = b.modified ? b.modified.getTime() : 0;
} else {
av = a.path.toLowerCase(); bv = b.path.toLowerCase();
}
if (av < bv) return -1 * sortDir;
if (av > bv) return 1 * sortDir;
return 0;
});
tbody.innerHTML = '';
sorted.forEach(function (e) {
var tr = doc.createElement('tr');
var td1 = doc.createElement('td');
var slash = e.path.lastIndexOf('/');
if (slash >= 0) {
var folder = doc.createElement('span');
folder.className = 'zip-folder';
folder.textContent = e.path.substring(0, slash + 1);
td1.appendChild(folder);
}
var name = doc.createElement('span');
name.className = 'zip-name';
name.textContent = e.name;
td1.appendChild(name);
var td2 = doc.createElement('td');
td2.className = 'zip-size';
td2.textContent = formatSize(e.size);
var td3 = doc.createElement('td');
td3.className = 'zip-date';
td3.textContent = formatDate(e.modified);
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
tbody.appendChild(tr);
});
// Update sort arrows
var ths = thead.querySelectorAll('th');
for (var i = 0; i < ths.length; i++) {
ths[i].classList.remove('zip-sort-asc', 'zip-sort-desc');
if (ths[i].dataset.key === sortKey) {
ths[i].classList.add(sortDir > 0 ? 'zip-sort-asc' : 'zip-sort-desc');
}
}
}
thead.querySelectorAll('th').forEach(function (th) {
th.addEventListener('click', function () {
var k = th.dataset.key;
if (sortKey === k) sortDir = -sortDir;
else { sortKey = k; sortDir = 1; }
render();
});
});
render();
}).catch(function (err) {
container.innerHTML = '<div class="zip-empty">Failed to read ZIP: '
+ escapeHtml(err.message || err) + '</div>';
});
}
// ── Public API ───────────────────────────────────────────────────────────
if (!root.zddc) root.zddc = {};
root.zddc.preview = {
TIFF_EXTENSIONS: TIFF_EXTENSIONS,
IMAGE_EXTENSIONS: IMAGE_EXTENSIONS,
TEXT_EXTENSIONS: TEXT_EXTENSIONS,
OFFICE_EXTENSIONS: OFFICE_EXTENSIONS,
isTiff: isTiff,
isImage: isImage,
isText: isText,
isZip: isZip,
isOffice: isOffice,
loadLibrary: loadLibrary,
renderTiff: renderTiff,
renderZipListing: renderZipListing,
formatSize: formatSize,
formatDate: formatDate
};
})(typeof window !== 'undefined' ? window : this);
/**
* ZDDC Classifier - Main Application
* Spreadsheet-based file renaming with Excel-like formulas
*/
(function() {
'use strict';
// Global application state
window.app = {
// File System
rootHandle: null,
// Data
folderTree: [],
selectedFolders: new Set(), // Multi-select support
lastSelectedFolderPath: null,
hideCompliant: false,
calculateSha256: false,
// DOM elements (populated on init)
dom: {},
// Modules (populated by other files)
modules: {}
};
/**
* Initialize the application
*/
function init() {
// Check browser compatibility
if (!checkBrowserCompatibility()) {
showBrowserWarning();
return;
}
// Cache DOM elements
cacheDOMElements();
// Set up event listeners
setupEventListeners();
// Show welcome screen
showWelcomeScreen();
}
/**
* Check if browser supports File System Access API
*/
function checkBrowserCompatibility() {
return 'showDirectoryPicker' in window;
}
/**
* Show browser compatibility warning
*/
function showBrowserWarning() {
const warning = document.getElementById('browserWarning');
const selectBtn = document.getElementById('selectDirectoryBtn');
if (warning) {
warning.classList.remove('hidden');
}
if (selectBtn) {
selectBtn.disabled = true;
selectBtn.textContent = 'Browser Not Supported';
}
}
/**
* Cache DOM element references
*/
function cacheDOMElements() {
app.dom = {
// Screens
welcomeScreen: document.getElementById('welcomeScreen'),
mainApp: document.getElementById('mainApp'),
// Header buttons
selectDirectoryBtn: document.getElementById('selectDirectoryBtn'),
refreshBtn: document.getElementById('refreshBtn'),
saveAllBtn: document.getElementById('saveAllBtn'),
cancelAllBtn: document.getElementById('cancelAllBtn'),
exportHashesBtn: document.getElementById('exportHashesBtn'),
sha256Checkbox: document.getElementById('sha256Checkbox'),
hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'),
// Folder tree
folderTree: document.getElementById('folderTree'),
folderTreePane: document.getElementById('folderTreePane'),
collapseTreeBtn: document.getElementById('collapseTreeBtn'),
autoScrollCheckbox: document.getElementById('autoScrollCheckbox'),
selectedFoldersCount: document.getElementById('selectedFoldersCount'),
// Spreadsheet
spreadsheet: document.getElementById('spreadsheet'),
spreadsheetBody: document.getElementById('spreadsheetBody'),
sha256Column: document.getElementById('sha256Column'),
// Stats
totalFiles: document.getElementById('totalFiles'),
modifiedFiles: document.getElementById('modifiedFiles'),
errorFiles: document.getElementById('errorFiles'),
// Preview
togglePreviewBtn: document.getElementById('togglePreviewBtn')
};
}
/**
* Set up event listeners
*/
function setupEventListeners() {
// Directory selection
app.dom.selectDirectoryBtn.addEventListener('click', handleSelectDirectory);
app.dom.refreshBtn.addEventListener('click', handleRefresh);
// Drag and drop on welcome screen
setupWelcomeDragDrop();
// Bulk actions
app.dom.saveAllBtn.addEventListener('click', handleSaveAll);
app.dom.cancelAllBtn.addEventListener('click', handleCancelAll);
// Export hashes
app.dom.exportHashesBtn.addEventListener('click', handleExportHashes);
// SHA256 toggle
app.dom.sha256Checkbox.addEventListener('change', handleSha256Toggle);
// Hide compliant toggle
app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
// Collapse tree button
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);
// Keyboard shortcuts
document.addEventListener('keydown', handleKeyDown);
// Resize handle
setupResizeHandle();
}
/**
* Handle collapse/expand folder tree pane
*/
function handleCollapseTree() {
const pane = app.dom.folderTreePane;
const btn = app.dom.collapseTreeBtn;
pane.classList.toggle('collapsed');
if (pane.classList.contains('collapsed')) {
// Clear any inline width from resize handle
pane.style.width = '';
btn.textContent = '▶';
btn.title = 'Expand folder tree';
} else {
btn.textContent = '◀';
btn.title = 'Collapse folder tree';
}
}
/**
* Set up folder tree resize handle
*/
function setupResizeHandle() {
const handle = document.getElementById('treeResizeHandle');
const pane = document.getElementById('folderTreePane');
if (!handle || !pane) return;
let isResizing = false;
let startX = 0;
let startWidth = 0;
handle.addEventListener('mousedown', (e) => {
isResizing = true;
startX = e.clientX;
startWidth = pane.offsetWidth;
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const delta = e.clientX - startX;
const newWidth = startWidth + delta;
// Respect min width only
if (newWidth >= 150) {
pane.style.width = newWidth + 'px';
}
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = '';
}
});
}
/**
* Set up drag-and-drop on the welcome screen
*/
function setupWelcomeDragDrop() {
const screen = app.dom.welcomeScreen;
if (!screen) return;
['dragenter', 'dragover'].forEach(evt => {
screen.addEventListener(evt, (e) => {
e.preventDefault();
screen.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(evt => {
screen.addEventListener(evt, (e) => {
e.preventDefault();
screen.classList.remove('drag-over');
});
});
screen.addEventListener('drop', async (e) => {
const item = e.dataTransfer.items && e.dataTransfer.items[0];
if (!item) return;
const handle = await item.getAsFileSystemHandle();
if (!handle || handle.kind !== 'directory') {
alert('Please drop a folder, not a file.');
return;
}
await openDirectory(handle);
});
}
/**
* Handle directory selection via button click
*/
async function handleSelectDirectory() {
try {
const dirHandle = await window.showDirectoryPicker();
await openDirectory(dirHandle);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error selecting directory:', err);
alert('Error selecting directory: ' + err.message);
}
}
}
/**
* Open a directory handle and initialize the application
*/
async function openDirectory(dirHandle) {
app.rootHandle = dirHandle;
// Hide welcome screen and show main UI
hideWelcomeScreen();
showMainUI();
// Initialize modules BEFORE scanning (so they're ready for store updates)
app.modules.spreadsheet.init(); // Subscribe to store
app.modules.selection.init();
app.modules.preview.init(); // After selection so it can listen for rowfocused
app.modules.resize.init();
app.modules.filter.init();
app.modules.sort.init();
app.modules.tree.setupKeyboardShortcuts();
// Now scan directory (this will trigger store updates and renders)
await app.modules.scanner.scanDirectory(dirHandle);
// Show refresh button now that a directory is loaded
if (app.dom.refreshBtn) { app.dom.refreshBtn.classList.remove('hidden'); }
}
/**
* Handle Refresh button - rescan current directory
*/
async function handleRefresh() {
if (!app.rootHandle) {
alert('No directory selected');
return;
}
try {
// Clear current data
app.folderTree = [];
app.selectedFolders.clear();
app.lastSelectedFolderPath = null;
// Reset store
app.modules.store.reset();
// Rescan directory (modules already initialized, just rescan)
await app.modules.scanner.scanDirectory(app.rootHandle);
} catch (err) {
console.error('Error refreshing directory:', err);
alert('Error refreshing directory: ' + err.message);
}
}
/**
* Handle Save All button
*/
async function handleSaveAll() {
if (!confirm('Save all modified files?')) return;
try {
app.dom.saveAllBtn.disabled = true;
await app.modules.spreadsheet.saveAllFiles();
} catch (err) {
console.error('Error saving files:', err);
alert('Error saving files: ' + err.message);
} finally {
app.dom.saveAllBtn.disabled = false;
}
}
/**
* Handle Cancel All button
*/
function handleCancelAll() {
if (!confirm('Cancel all changes?')) return;
app.modules.spreadsheet.cancelAllChanges();
}
/**
* Handle Export Hashes button
*/
function handleExportHashes() {
app.modules.excel.exportHashes();
}
/**
* Handle SHA256 checkbox toggle
*/
function handleSha256Toggle() {
app.calculateSha256 = app.dom.sha256Checkbox.checked;
// Show/hide SHA256 column
if (app.calculateSha256) {
app.dom.sha256Column.classList.remove('hidden');
} else {
app.dom.sha256Column.classList.add('hidden');
}
// Re-render table
app.modules.spreadsheet.render();
}
/**
* Handle Hide Compliant checkbox toggle
*/
function handleHideCompliantToggle() {
app.hideCompliant = app.dom.hideCompliantCheckbox.checked;
app.modules.store.setHideCompliant(app.hideCompliant);
}
/**
* Handle keyboard shortcuts
*/
function handleKeyDown(e) {
// Ctrl+S - Save All
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
if (!app.dom.saveAllBtn.disabled) {
handleSaveAll();
}
}
// Escape - Cancel editing
if (e.key === 'Escape') {
app.modules.spreadsheet.cancelEditing();
}
}
/**
* Show welcome screen (empty-state overlay)
*/
function showWelcomeScreen() {
if (app.dom.welcomeScreen) {
app.dom.welcomeScreen.classList.remove('hidden');
}
}
/**
* Hide welcome screen (empty-state overlay)
*/
function hideWelcomeScreen() {
if (app.dom.welcomeScreen) {
app.dom.welcomeScreen.classList.add('hidden');
}
}
/**
* Show main UI (no-op: main UI is always rendered)
*/
function showMainUI() {
// Main app is always visible; only the empty-state overlay is toggled
}
/**
* Update stats display
*/
function updateStats() {
const files = app.modules.store.getDisplayFiles();
const totalFiles = files.length;
const modifiedFiles = files.filter(f => f.isDirty).length;
const errorFiles = files.filter(f => f.error).length;
app.dom.totalFiles.textContent = `${totalFiles} file${totalFiles !== 1 ? 's' : ''}`;
app.dom.modifiedFiles.textContent = `${modifiedFiles} modified`;
if (errorFiles > 0) {
app.dom.errorFiles.textContent = `${errorFiles} error${errorFiles !== 1 ? 's' : ''}`;
app.dom.errorFiles.classList.remove('hidden');
} else {
app.dom.errorFiles.classList.add('hidden');
}
// Enable/disable bulk action buttons
app.dom.saveAllBtn.disabled = modifiedFiles === 0;
app.dom.cancelAllBtn.disabled = modifiedFiles === 0;
// Enable/disable export hashes button
app.dom.exportHashesBtn.disabled = totalFiles === 0 || !app.calculateSha256;
}
// Export functions for use by other modules
app.modules.app = {
updateStats
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
/**
* Classifier utilities — thin convenience layer over window.zddc.
*/
(function() {
'use strict';
/**
* Compute new filename from file fields.
* ZDDC format: trackingNumber_revision (status) - title.ext
* Falls back to original filename if any required ZDDC field is missing.
*/
function computeNewFilename(file) {
if (file.manualFilename) {
return file.manualFilename;
}
const formatted = zddc.formatFilename({
trackingNumber: file.trackingNumber || '',
revision: file.revision || '',
status: file.status || '',
title: file.title || '',
extension: file.extension || '',
});
return formatted || zddc.joinExtension(file.originalFilename, file.extension);
}
/**
* Get column value from file object.
*/
function getColumnValue(file, columnName) {
switch (columnName) {
case 'original': return file.originalFilename || '';
case 'extension': return file.extension || '';
case 'new':
case 'newFilename': return file.manualFilename || computeNewFilename(file);
case 'trackingNumber': return file.trackingNumber || '';
case 'revision': return file.revision || '';
case 'status': return file.status || '';
case 'title': return file.title || '';
case 'sha256': return file.sha256 || '';
default: return '';
}
}
/**
* Get MIME type from file extension (no leading dot).
*/
const MIME_TYPES = {
pdf: 'application/pdf',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
svg: 'image/svg+xml',
webp: 'image/webp',
bmp: 'image/bmp',
ico: 'image/x-icon',
txt: 'text/plain',
md: 'text/markdown',
json: 'application/json',
xml: 'application/xml',
csv: 'text/csv',
html: 'text/html',
css: 'text/css',
js: 'text/javascript',
};
function getMimeType(extension) {
return MIME_TYPES[(extension || '').toLowerCase()] || 'application/octet-stream';
}
window.app.modules.utils = {
computeNewFilename,
getColumnValue,
getMimeType,
};
})();
(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 };
})();
/**
* Store Module
* Single source of truth for all application state
* Manages files, folders, sorting, filtering
*/
(function() {
'use strict';
// State
const state = {
// Directory structure
rootHandle: null,
folderTree: [],
selectedFolders: new Set(),
// Files
allFiles: [], // All files from selected folders
displayFiles: [], // After sorting and filtering
// Sort state
sortColumns: [], // [{column: 'original', direction: 'asc'}]
// Filter state
filters: {}, // {columnName: AST (from zddc.filter.parse)}
// UI state
hideCompliant: false,
calculateSha256: false
};
// Listeners for state changes
const listeners = {
'files': [],
'folders': [],
'sort': [],
'filter': []
};
/**
* Set sort columns
*/
function on(event, callback) {
if (listeners[event]) {
listeners[event].push(callback);
}
}
/**
* Notify listeners of state change
*/
function notify(event) {
if (listeners[event]) {
listeners[event].forEach(cb => cb());
}
}
/**
* Set root directory handle
*/
function setRootHandle(handle) {
state.rootHandle = handle;
}
/**
* Set folder tree
*/
function setFolderTree(tree) {
state.folderTree = tree;
notify('folders');
}
/**
* Select/deselect folder
*/
function toggleFolder(folderPath) {
if (state.selectedFolders.has(folderPath)) {
state.selectedFolders.delete(folderPath);
} else {
state.selectedFolders.add(folderPath);
}
loadFilesFromSelectedFolders();
}
/**
* Select multiple folders
*/
function setSelectedFolders(folderPaths) {
state.selectedFolders.clear();
folderPaths.forEach(path => state.selectedFolders.add(path));
loadFilesFromSelectedFolders();
}
/**
* Load files from selected folders
*/
function loadFilesFromSelectedFolders() {
state.allFiles = [];
if (state.selectedFolders.size === 0) {
updateDisplayFiles();
return;
}
// Collect files from selected folders
for (const folderPath of state.selectedFolders) {
const folder = findFolderByPath(folderPath);
if (folder && folder.files) {
const files = folder.files.filter(f => !f.isDirectory);
state.allFiles.push(...files);
}
}
// Apply default sort if no sort set
if (state.sortColumns.length === 0) {
state.sortColumns = [{ column: 'original', direction: 'asc' }];
}
updateDisplayFiles();
}
/**
* Find folder by path in tree
*/
function findFolderByPath(path) {
function search(folders) {
for (const folder of folders) {
if (folder.path === path) return folder;
if (folder.children) {
const found = search(folder.children);
if (found) return found;
}
}
return null;
}
return search(state.folderTree);
}
/**
* Update display files (apply sort, filter, hide compliant)
*/
function updateDisplayFiles() {
let files = [...state.allFiles];
// Apply filters
files = applyFilters(files);
// Apply hide compliant
if (state.hideCompliant) {
files = files.filter(file => {
const newFilename = computeNewFilename(file);
const validation = validateFilename(newFilename);
return !validation.isValid;
});
}
// Apply sort
files = applySort(files);
state.displayFiles = files;
notify('files');
}
/**
* Apply filters to files using zddc.filter ASTs
*/
function applyFilters(files) {
if (Object.keys(state.filters).length === 0) {
return files;
}
return files.filter(file => {
for (const [columnName, ast] of Object.entries(state.filters)) {
const value = getColumnValue(file, columnName);
if (!window.zddc.filter.matches(value, ast)) {
return false;
}
}
return true;
});
}
/**
* Apply sort to files
*/
function applySort(files) {
if (state.sortColumns.length === 0) {
return files;
}
return files.sort((a, b) => {
for (const sort of state.sortColumns) {
const result = compareValues(a, b, sort.column, sort.direction);
if (result !== 0) return result;
}
return 0;
});
}
/**
* Compare two values for sorting
*/
function compareValues(a, b, columnName, direction) {
let aVal = getColumnValue(a, columnName);
let bVal = getColumnValue(b, columnName);
const comparison = String(aVal).localeCompare(String(bVal), undefined, {
numeric: true,
sensitivity: 'base'
});
return direction === 'asc' ? comparison : -comparison;
}
/**
* Get column value from file (delegates to utils)
*/
function getColumnValue(file, columnName) {
return window.app.modules.utils.getColumnValue(file, columnName);
}
function computeNewFilename(file) {
return window.app.modules.utils.computeNewFilename(file);
}
/**
* Validate filename
*/
function validateFilename(filename) {
// Use existing validator module
if (window.app.modules.validator) {
return window.app.modules.validator.validateFilename(filename);
}
return { isValid: true, errors: [], warnings: [] };
}
/**
* Match filter text against value
*/
function matchesFilter(value, filterText) {
// Simple contains for now - can enhance later
return String(value).toLowerCase().includes(filterText.toLowerCase());
}
/**
* Set sort columns
*/
function setSortColumns(columns) {
state.sortColumns = columns;
updateDisplayFiles();
}
/**
* Toggle sort on column
*/
function toggleSort(columnName, multiSort) {
if (!multiSort) {
state.sortColumns = [];
}
const existingIndex = state.sortColumns.findIndex(s => s.column === columnName);
if (existingIndex >= 0) {
const current = state.sortColumns[existingIndex];
if (current.direction === 'asc') {
current.direction = 'desc';
} else {
state.sortColumns.splice(existingIndex, 1);
}
} else {
state.sortColumns.push({ column: columnName, direction: 'asc' });
}
updateDisplayFiles();
notify('sort');
}
/**
* Set filter for column. ast is the pre-parsed zddc.filter AST.
*/
function setFilter(columnName, filterText, ast) {
if (filterText && ast && ast.length > 0) {
state.filters[columnName] = ast;
} else {
delete state.filters[columnName];
}
updateDisplayFiles();
}
/**
* Replace all filters at once. filtersObj is {columnName: rawString}.
* Parses each value. Pass {} to clear all filters.
*/
function setAllFilters(filtersObj) {
state.filters = {};
for (const [columnName, raw] of Object.entries(filtersObj)) {
if (raw) {
const ast = window.zddc.filter.parse(raw);
if (ast && ast.length > 0) {
state.filters[columnName] = ast;
}
}
}
updateDisplayFiles();
}
/**
* Set hide compliant flag
*/
function setHideCompliant(hide) {
state.hideCompliant = hide;
updateDisplayFiles();
}
/**
* Update file data
*/
function updateFile(index, updates) {
const file = state.displayFiles[index];
if (!file) return;
// Apply updates
Object.assign(file, updates);
// Mark as dirty unless explicitly set to false
if (updates.isDirty !== false) {
file.isDirty = true;
}
// Notify listeners (will trigger re-render)
notify('files');
}
/**
* Update file field (for editing)
*/
function updateFileField(index, fieldName, value) {
const file = state.displayFiles[index];
if (!file) return;
file[fieldName] = value;
file.autoPopulated = false; // Clear auto-populated flag
// Re-evaluate dirty: if every field still matches the parsed original,
// and there is no manual filename override, the file is clean again.
file.isDirty = _isFileDirty(file);
// Notify listeners
notify('files');
}
/**
* A file is dirty if its computed filename differs from the original,
* or if it has a manual filename override.
*/
function _isFileDirty(file) {
if (file.manualFilename) return true;
const computed = zddc.formatFilename({
trackingNumber: file.trackingNumber || '',
revision: file.revision || '',
status: file.status || '',
title: file.title || '',
extension: file.extension || '',
});
const original = zddc.joinExtension(file.originalFilename, file.extension);
// If formatFilename returns '' (missing fields) fall back to original — not dirty
return computed !== '' && computed !== original;
}
/**
* Get display files (what should be shown in table)
*/
function getDisplayFiles() {
return state.displayFiles;
}
/**
* Get all files (unfiltered)
*/
function getAllFiles() {
return state.allFiles;
}
/**
* Get sort columns
*/
function getSortColumns() {
return state.sortColumns;
}
/**
* Get selected folder count
*/
function getSelectedFolderCount() {
return state.selectedFolders.size;
}
/**
* Get state (read-only)
*/
function getState() {
return {
rootHandle: state.rootHandle,
folderTree: state.folderTree,
selectedFolders: Array.from(state.selectedFolders),
allFiles: state.allFiles,
displayFiles: state.displayFiles,
sortColumns: state.sortColumns,
filters: state.filters,
hideCompliant: state.hideCompliant
};
}
/**
* Reset all state
*/
function reset() {
state.rootHandle = null;
state.folderTree = [];
state.selectedFolders.clear();
state.allFiles = [];
state.displayFiles = [];
state.sortColumns = [];
state.filters = {};
state.hideCompliant = false;
notify('files');
notify('folders');
}
// Export
window.app.modules.store = {
on,
notify,
setRootHandle,
setFolderTree,
toggleFolder,
setSelectedFolders,
toggleSort,
setFilter,
setAllFilters,
setHideCompliant,
updateFile,
updateFileField,
getDisplayFiles,
getAllFiles,
getSortColumns,
getSelectedFolderCount,
getState,
reset
};
})();
/**
* ZDDC Validation Module
* Validates file names against ZDDC conventions using the shared zddc library.
*/
(function() {
'use strict';
/**
* Validate a filename and return a detailed result.
* Delegates ZDDC pattern checking to the shared zddc.parseFilename() library.
*/
function validateFilename(filename) {
const errors = [];
const warnings = [];
if (!filename) {
errors.push('Filename is empty.');
return { isValid: false, warnings, errors };
}
const parsed = zddc.parseFilename(filename);
if (!parsed || !parsed.valid) {
errors.push('Filename does not match ZDDC format: trackingNumber_revision (status) - title.ext');
} else if (!zddc.isValidStatus(parsed.status)) {
errors.push('Invalid status code "' + parsed.status + '". Valid codes: ' + zddc.STATUSES.join(', '));
}
if (filename.length > 255) {
warnings.push('Filename is very long (>255 characters)');
}
const invalidChars = /[<>:"|?*]/;
if (invalidChars.test(filename)) {
errors.push('Filename contains invalid characters: < > : " | ? *');
}
return {
isValid: errors.length === 0,
warnings,
errors
};
}
window.app.modules.validator = {
validateFilename
};
})();
/**
* Directory Scanner Module
* Scans directories and collects files
*/
(function() {
'use strict';
// Store ZIP data for later access
const zipCache = new Map(); // path -> { zip: JSZip, fileHandle: FileSystemFileHandle }
/**
* Scan directory and build folder tree with files
*/
async function scanDirectory(dirHandle, preserveState = false) {
// Save current state if preserving
let savedExpanded = new Set();
let savedSelected = new Set();
if (preserveState) {
savedExpanded = getExpandedPaths(window.app.folderTree);
savedSelected = new Set(window.app.selectedFolders);
}
// Clear ZIP cache
zipCache.clear();
// Map to store files by folder handle (or ZIP path for virtual folders)
const foldersMap = new Map();
// Recursively scan
await scanFolder(dirHandle, foldersMap, dirHandle.name);
// Build tree structure
window.app.folderTree = window.app.modules.tree.buildTree(dirHandle, foldersMap);
// Set in store
window.app.modules.store.setFolderTree(window.app.folderTree);
if (preserveState) {
// Restore expanded state
restoreExpandedPaths(window.app.folderTree, savedExpanded);
// Restore selection
window.app.selectedFolders = savedSelected;
// Render without changing selection
window.app.modules.tree.render();
window.app.modules.store.setSelectedFolders(savedSelected);
} else {
// Render tree
window.app.modules.tree.render();
// Auto-expand and select all folders
window.app.modules.tree.expandAll();
window.app.modules.tree.selectAll();
}
}
/**
* Get all expanded folder paths from tree
*/
function getExpandedPaths(folders, paths = new Set()) {
for (const folder of folders) {
if (folder.expanded) {
paths.add(folder.path);
}
if (folder.children) {
getExpandedPaths(folder.children, paths);
}
}
return paths;
}
/**
* Restore expanded state to tree
*/
function restoreExpandedPaths(folders, expandedPaths) {
for (const folder of folders) {
folder.expanded = expandedPaths.has(folder.path);
if (folder.children) {
restoreExpandedPaths(folder.children, expandedPaths);
}
}
}
/**
* Recursively scan a folder
*/
async function scanFolder(dirHandle, foldersMap, currentPath) {
const items = [];
try {
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file') {
// Create file object
const file = await createFileObject(entry, dirHandle);
if (file) {
items.push(file);
// Check if it's a ZIP file - scan its contents
if (file.extension === 'zip' && typeof JSZip !== 'undefined') {
await scanZipFile(file, foldersMap, currentPath, items);
}
}
} else if (entry.kind === 'directory') {
// Add directory reference
items.push({
handle: entry,
isDirectory: true
});
// Recursively scan subdirectory
const childPath = currentPath + '/' + entry.name;
await scanFolder(entry, foldersMap, childPath);
}
}
} catch (err) {
console.error('Error scanning folder:', dirHandle.name, err);
}
// Store files for this folder
foldersMap.set(dirHandle, items);
}
/**
* Scan a ZIP file and add its contents as virtual folders
*/
async function scanZipFile(zipFileObj, foldersMap, parentPath, parentItems) {
try {
const fileObj = await zipFileObj.handle.getFile();
const arrayBuffer = await fileObj.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const zipPath = parentPath + '/' + zddc.joinExtension(zipFileObj.originalFilename, zipFileObj.extension);
// Cache the ZIP for later extraction
zipCache.set(zipPath, {
zip: zip,
fileHandle: zipFileObj.handle,
folderHandle: zipFileObj.folderHandle
});
// Mark the file as a ZIP container
zipFileObj.isZipContainer = true;
zipFileObj.zipPath = zipPath;
// Build virtual folder structure from ZIP contents
const virtualFolders = new Map(); // path -> { files: [], subdirs: Set }
virtualFolders.set(zipPath, { files: [], subdirs: new Set() });
zip.forEach((relativePath, zipEntry) => {
if (zipEntry.dir) {
// It's a directory
const dirPath = zipPath + '/' + relativePath.replace(/\/$/, '');
if (!virtualFolders.has(dirPath)) {
virtualFolders.set(dirPath, { files: [], subdirs: new Set() });
}
// Add to parent's subdirs
const parentDir = dirPath.substring(0, dirPath.lastIndexOf('/'));
if (virtualFolders.has(parentDir)) {
virtualFolders.get(parentDir).subdirs.add(dirPath);
}
} else {
// It's a file
const fileName = relativePath.split('/').pop();
const fileDir = relativePath.includes('/')
? zipPath + '/' + relativePath.substring(0, relativePath.lastIndexOf('/'))
: zipPath;
// Ensure parent directories exist
ensureVirtualPath(virtualFolders, zipPath, fileDir);
// Create virtual file object
const split = zddc.splitExtension(fileName);
const virtualFile = {
originalFilename: split.name,
extension: split.extension,
size: zipEntry._data ? zipEntry._data.uncompressedSize : 0,
lastModified: zipEntry.date ? zipEntry.date.getTime() : Date.now(),
// Virtual file markers
isVirtual: true,
zipPath: zipPath,
zipEntryPath: relativePath,
// Editable fields
trackingNumber: '',
revision: '',
status: '',
title: '',
// State
isDirty: false,
error: false,
errorMessage: '',
validation: null,
sha256: null
};
virtualFolders.get(fileDir).files.push(virtualFile);
}
});
// Convert virtual folders to format compatible with tree builder
// Create a virtual handle for the ZIP root
const zipVirtualHandle = {
name: zddc.joinExtension(zipFileObj.originalFilename, zipFileObj.extension),
kind: 'directory',
isZipRoot: true,
zipPath: zipPath
};
// Store virtual folder data
buildVirtualFolderMap(virtualFolders, zipPath, foldersMap, zipVirtualHandle);
// Add ZIP as a virtual directory in parent
parentItems.push({
handle: zipVirtualHandle,
isDirectory: true,
isZipRoot: true
});
} catch (err) {
console.error('Error scanning ZIP file:', zipFileObj.originalFilename, err);
}
}
/**
* Ensure all parent directories exist in virtual folder map
*/
function ensureVirtualPath(virtualFolders, zipPath, targetPath) {
if (virtualFolders.has(targetPath)) return;
const parts = targetPath.substring(zipPath.length + 1).split('/').filter(p => p);
let currentPath = zipPath;
for (const part of parts) {
const parentPath = currentPath;
currentPath = currentPath + '/' + part;
if (!virtualFolders.has(currentPath)) {
virtualFolders.set(currentPath, { files: [], subdirs: new Set() });
}
if (virtualFolders.has(parentPath)) {
virtualFolders.get(parentPath).subdirs.add(currentPath);
}
}
}
/**
* Build virtual folder entries for the foldersMap
* Uses path strings as keys for virtual folders to avoid object reference issues
*/
function buildVirtualFolderMap(virtualFolders, zipPath, foldersMap, zipVirtualHandle) {
const rootData = virtualFolders.get(zipPath);
if (!rootData) return;
// Create items array for ZIP root
const rootItems = [...rootData.files];
// Add subdirectories
for (const subdirPath of rootData.subdirs) {
const subdirName = subdirPath.split('/').pop();
const subdirHandle = {
name: subdirName,
kind: 'directory',
isVirtualDir: true,
virtualPath: subdirPath,
zipPath: zipPath
};
rootItems.push({
handle: subdirHandle,
isDirectory: true,
isVirtualDir: true
});
// Recursively add subdir contents
buildVirtualSubfolder(virtualFolders, subdirPath, foldersMap, zipPath);
}
// Store with both the handle object AND the path string as keys
// This ensures lookup works regardless of which reference is used
foldersMap.set(zipVirtualHandle, rootItems);
foldersMap.set(zipPath, rootItems); // Path-based key for tree building
}
/**
* Recursively build virtual subfolder entries
*/
function buildVirtualSubfolder(virtualFolders, folderPath, foldersMap, zipPath) {
const folderData = virtualFolders.get(folderPath);
if (!folderData) return;
const folderName = folderPath.split('/').pop();
const folderHandle = {
name: folderName,
kind: 'directory',
isVirtualDir: true,
virtualPath: folderPath,
zipPath: zipPath
};
const items = [...folderData.files];
// Store with path string key for tree building lookup
foldersMap.set(folderPath, items);
// Add subdirectories
for (const subdirPath of folderData.subdirs) {
const subdirName = subdirPath.split('/').pop();
const subdirHandle = {
name: subdirName,
kind: 'directory',
isVirtualDir: true,
virtualPath: subdirPath,
zipPath: zipPath
};
items.push({
handle: subdirHandle,
isDirectory: true,
isVirtualDir: true
});
// Recursively add subdir contents
buildVirtualSubfolder(virtualFolders, subdirPath, foldersMap, zipPath);
}
foldersMap.set(folderHandle, items);
}
/**
* Get cached ZIP data
*/
function getZipCache(zipPath) {
return zipCache.get(zipPath);
}
/**
* Extract a ZIP file to its parent directory
*/
async function extractZip(zipPath) {
const cached = zipCache.get(zipPath);
if (!cached) {
throw new Error('ZIP not found in cache');
}
const { zip, folderHandle } = cached;
// Get the ZIP filename without extension for the extract folder name
const zipName = zipPath.split('/').pop();
const extractFolderName = zipName.replace(/\.zip$/i, '');
// Create extraction folder
const extractFolder = await folderHandle.getDirectoryHandle(extractFolderName, { create: true });
// Extract all files
const entries = [];
zip.forEach((relativePath, zipEntry) => {
if (!zipEntry.dir) {
entries.push({ path: relativePath, entry: zipEntry });
}
});
for (const { path, entry } of entries) {
try {
// Create subdirectories if needed
const parts = path.split('/');
const fileName = parts.pop();
let currentDir = extractFolder;
for (const part of parts) {
if (part) {
currentDir = await currentDir.getDirectoryHandle(part, { create: true });
}
}
// Write file
const content = await entry.async('arraybuffer');
const fileHandle = await currentDir.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
} catch (err) {
console.error('Error extracting file:', path, err);
}
}
return extractFolderName;
}
/**
* Create file object with metadata
*/
async function createFileObject(fileHandle, folderHandle) {
try {
const file = await fileHandle.getFile();
const split = zddc.splitExtension(file.name);
return {
handle: fileHandle,
folderHandle: folderHandle,
originalFilename: split.name,
extension: split.extension,
size: file.size,
lastModified: file.lastModified,
// Editable fields
trackingNumber: '',
revision: '',
status: '',
title: '',
// State
isDirty: false,
error: false,
errorMessage: '',
validation: null,
sha256: null
// folderPath will be added later in buildTree
};
} catch (err) {
console.error('Error reading file:', fileHandle.name, err);
return null;
}
}
// Export module
window.app.modules.scanner = {
scanDirectory,
getZipCache,
extractZip
};
})();
/**
* Folder Tree Module
* Handles folder tree rendering and multi-select
*/
(function() {
'use strict';
/**
* Render the folder tree
*/
function render() {
const container = window.app.dom.folderTree;
container.innerHTML = '';
if (window.app.folderTree.length === 0) {
container.innerHTML = '<div class="empty-state">No folders found</div>';
return;
}
window.app.folderTree.forEach(folder => {
const element = createFolderElement(folder);
container.appendChild(element);
});
updateSelectedCount();
}
/**
* Create a folder element
*/
function createFolderElement(folder, level = 0) {
const div = document.createElement('div');
const item = document.createElement('div');
item.className = 'folder-item';
item.dataset.path = folder.path;
item.style.paddingLeft = `${level * 1.5}rem`;
// Check if selected
if (window.app.selectedFolders.has(folder.path)) {
item.classList.add('selected');
}
// Toggle button (if has children)
const toggle = document.createElement('span');
toggle.className = 'folder-toggle';
if (folder.children && folder.children.length > 0) {
toggle.textContent = folder.expanded ? '▼' : '▶';
toggle.addEventListener('click', (e) => {
e.stopPropagation();
const recursive = e.ctrlKey || e.metaKey;
toggleFolder(folder, recursive);
});
} else {
toggle.textContent = ' ';
}
item.appendChild(toggle);
// Folder icon (different for ZIP files)
const icon = document.createElement('span');
icon.className = 'folder-icon';
if (folder.isZipRoot) {
icon.innerHTML = '&#128230;'; // 📦
} else if (folder.isVirtualDir) {
icon.innerHTML = '&#128194;'; // 📂
} else {
icon.innerHTML = '&#128193;'; // 📁
}
item.appendChild(icon);
// Folder name
const name = document.createElement('span');
name.className = 'folder-name';
name.textContent = folder.name;
item.appendChild(name);
// File count
const count = document.createElement('span');
count.className = 'folder-count';
count.textContent = `(${folder.fileCount || 0})`;
item.appendChild(count);
// Extract button for ZIP roots
if (folder.isZipRoot) {
const extractBtn = document.createElement('button');
extractBtn.className = 'btn btn-sm zip-extract-btn';
extractBtn.textContent = '📤 Extract';
extractBtn.title = 'Extract ZIP contents to folder';
extractBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await handleExtractZip(folder);
});
item.appendChild(extractBtn);
}
// Extract All button for folders with ZIP descendants (but not ZIP roots themselves)
if (!folder.isZipRoot && !folder.isVirtualDir) {
const zipCount = countZipDescendants(folder);
if (zipCount > 0) {
const extractAllBtn = document.createElement('button');
extractAllBtn.className = 'btn btn-sm zip-extract-all-btn';
extractAllBtn.textContent = `📤 Extract All (${zipCount})`;
extractAllBtn.title = `Extract all ${zipCount} ZIP file(s) in this folder`;
extractAllBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await handleExtractAllZips(folder);
});
item.appendChild(extractAllBtn);
}
}
// Click handler for selection
item.addEventListener('click', (e) => {
handleFolderClick(folder, e);
});
div.appendChild(item);
// Children (if expanded)
if (folder.expanded && folder.children && folder.children.length > 0) {
const childrenDiv = document.createElement('div');
childrenDiv.className = 'folder-children';
folder.children.forEach(child => {
const childElement = createFolderElement(child, level + 1);
childrenDiv.appendChild(childElement);
});
div.appendChild(childrenDiv);
}
return div;
}
/**
* Handle folder click with multi-select support
*/
function handleFolderClick(folder, event) {
if (event.ctrlKey || event.metaKey) {
// Ctrl+Click: Toggle selection
if (window.app.selectedFolders.has(folder.path)) {
window.app.selectedFolders.delete(folder.path);
} else {
window.app.selectedFolders.add(folder.path);
}
} else if (event.shiftKey) {
// Shift+Click: Range selection
const visibleFolders = getVisibleFolders();
const currentIndex = visibleFolders.findIndex(f => f.path === folder.path);
if (currentIndex >= 0 && window.app.lastSelectedFolderPath) {
const lastIndex = visibleFolders.findIndex(f => f.path === window.app.lastSelectedFolderPath);
if (lastIndex >= 0) {
const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex);
// Select range
for (let i = start; i <= end; i++) {
window.app.selectedFolders.add(visibleFolders[i].path);
}
}
} else {
window.app.selectedFolders.add(folder.path);
}
} else {
// Normal click: Single selection
window.app.selectedFolders.clear();
window.app.selectedFolders.add(folder.path);
}
// Remember last selected for shift-click
window.app.lastSelectedFolderPath = folder.path;
// Re-render tree
render();
// Load files from selected folders
loadFilesFromSelectedFolders();
}
/**
* Handle ZIP extraction
*/
async function handleExtractZip(folder) {
if (!folder.isZipRoot || !folder.zipPath) return;
try {
const confirmed = confirm(`Extract "${folder.name}" to a new folder?\n\nThis will create a folder named "${folder.name.replace(/\.zip$/i, '')}" with the ZIP contents.`);
if (!confirmed) return;
// Show extracting state
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-btn`);
if (btn) {
btn.textContent = '⏳ Extracting...';
btn.disabled = true;
}
await window.app.modules.scanner.extractZip(folder.zipPath);
// Auto-refresh preserving tree state
await window.app.modules.scanner.scanDirectory(window.app.rootHandle, true);
} catch (err) {
console.error('Error extracting ZIP:', err);
alert('Error extracting ZIP: ' + err.message);
// Reset button
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-btn`);
if (btn) {
btn.textContent = '📤 Extract';
btn.disabled = false;
}
}
}
/**
* Count ZIP descendants in a folder
*/
function countZipDescendants(folder) {
let count = 0;
if (folder.children) {
for (const child of folder.children) {
if (child.isZipRoot) {
count++;
}
count += countZipDescendants(child);
}
}
return count;
}
/**
* Get all ZIP folders as flat list
*/
function getZipDescendants(folder, zips = []) {
if (folder.children) {
for (const child of folder.children) {
if (child.isZipRoot) {
zips.push(child);
}
getZipDescendants(child, zips);
}
}
return zips;
}
/**
* Handle extracting all ZIPs in a folder
*/
async function handleExtractAllZips(folder) {
const zips = getZipDescendants(folder);
if (zips.length === 0) return;
const confirmed = confirm(`Extract ${zips.length} ZIP file(s)?\n\nThis will create folders for each ZIP with their contents.`);
if (!confirmed) return;
try {
// Show extracting state on button
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-all-btn`);
if (btn) {
btn.textContent = '⏳ Extracting...';
btn.disabled = true;
}
// Extract all ZIPs
for (const zip of zips) {
if (zip.zipPath) {
await window.app.modules.scanner.extractZip(zip.zipPath);
}
}
// Auto-refresh preserving tree state
await window.app.modules.scanner.scanDirectory(window.app.rootHandle, true);
} catch (err) {
console.error('Error extracting ZIPs:', err);
alert('Error extracting ZIPs: ' + err.message);
// Reset button
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-all-btn`);
if (btn) {
btn.textContent = `📤 Extract All (${zips.length})`;
btn.disabled = false;
}
}
}
/**
* Toggle folder expansion
*/
function toggleFolder(folder, recursive = false) {
folder.expanded = !folder.expanded;
if (recursive && folder.children) {
// Recursively expand/collapse all children
const newState = folder.expanded;
function setAllExpanded(f) {
f.expanded = newState;
if (f.children) {
f.children.forEach(setAllExpanded);
}
}
folder.children.forEach(setAllExpanded);
}
render();
}
/**
* Load files from all selected folders
*/
async function loadFilesFromSelectedFolders() {
// Use store to manage files
window.app.modules.store.setSelectedFolders(Array.from(window.app.selectedFolders));
}
/**
* Find folder by path in tree
*/
function findFolderByPath(path) {
function search(folders) {
for (const folder of folders) {
if (folder.path === path) {
return folder;
}
if (folder.children) {
const found = search(folder.children);
if (found) return found;
}
}
return null;
}
return search(window.app.folderTree);
}
/**
* Update selected folders count
*/
function updateSelectedCount() {
const count = window.app.selectedFolders.size;
window.app.dom.selectedFoldersCount.textContent =
`${count} folder${count !== 1 ? 's' : ''} selected`;
}
/**
* Build folder tree from scanned data
*/
function buildTree(rootHandle, foldersMap) {
const tree = [];
// Convert flat map to tree structure
function buildNode(handle, path) {
// For virtual folders, look up by path string; for real folders, use handle
let files;
if (handle.isVirtualDir || handle.isZipRoot) {
files = foldersMap.get(handle.virtualPath || handle.zipPath) || [];
} else {
files = foldersMap.get(handle) || [];
}
// Add folderPath to each file for folder highlighting (filter out null files)
files.filter(file => file !== null).forEach(file => {
if (!file.isDirectory) {
file.folderPath = path;
}
});
// Filter out null files for the node
const validFiles = files.filter(f => f !== null);
const node = {
name: handle.name,
path: path,
handle: handle,
files: validFiles,
fileCount: validFiles.length,
children: [],
expanded: false
};
// Mark ZIP-related nodes
if (handle.isZipRoot) {
node.isZipRoot = true;
node.zipPath = handle.zipPath;
}
if (handle.isVirtualDir) {
node.isVirtualDir = true;
node.zipPath = handle.zipPath;
}
return node;
}
// Recursively build tree
function addChildren(node) {
// Get subdirectories (filter out null files first)
// For virtual folders, look up by path string
let files;
if (node.handle.isVirtualDir || node.handle.isZipRoot) {
files = foldersMap.get(node.handle.virtualPath || node.handle.zipPath) || [];
} else {
files = foldersMap.get(node.handle) || [];
}
const validFiles = files.filter(f => f !== null);
const subdirs = validFiles.filter(f => f.isDirectory);
subdirs.forEach(subdir => {
const childPath = node.path + '/' + subdir.handle.name;
const childNode = buildNode(subdir.handle, childPath);
addChildren(childNode);
node.children.push(childNode);
});
// Update file count to exclude directories and null files
node.files = validFiles.filter(f => !f.isDirectory);
node.fileCount = node.files.length;
}
// Build root
const root = buildNode(rootHandle, rootHandle.name);
addChildren(root);
// Expand root by default
root.expanded = true;
tree.push(root);
return tree;
}
/**
* Get all currently visible folders (expanded tree)
*/
function getVisibleFolders() {
const visible = [];
function traverse(folders) {
for (const folder of folders) {
visible.push(folder);
if (folder.expanded && folder.children) {
traverse(folder.children);
}
}
}
traverse(window.app.folderTree);
return visible;
}
/**
* Select all visible folders
*/
function selectAllVisible() {
const visible = getVisibleFolders();
window.app.selectedFolders.clear();
visible.forEach(f => window.app.selectedFolders.add(f.path));
render();
loadFilesFromSelectedFolders();
}
/**
* Expand all folders in tree
*/
function expandAll() {
function setAllExpanded(folder) {
folder.expanded = true;
if (folder.children) {
folder.children.forEach(setAllExpanded);
}
}
window.app.folderTree.forEach(setAllExpanded);
render();
}
/**
* Select all folders in tree
*/
function selectAll() {
function collectAllPaths(folders, paths = []) {
folders.forEach(folder => {
paths.push(folder.path);
if (folder.children) {
collectAllPaths(folder.children, paths);
}
});
return paths;
}
const allPaths = collectAllPaths(window.app.folderTree);
allPaths.forEach(path => window.app.selectedFolders.add(path));
render();
loadFilesFromSelectedFolders();
}
/**
* Set up keyboard shortcuts for folder tree
*/
function setupKeyboardShortcuts() {
const container = window.app.dom.folderTree;
container.addEventListener('keydown', (e) => {
// Ctrl+A: Select all visible
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
e.preventDefault();
selectAllVisible();
}
});
// Make container focusable
container.tabIndex = 0;
}
// Export module
window.app.modules.tree = {
render,
buildTree,
loadFilesFromSelectedFolders,
setupKeyboardShortcuts,
expandAll,
selectAll
};
})();
/**
* Spreadsheet Module
* Handles table rendering, cell editing, and file operations
*/
(function() {
'use strict';
let editingCell = null;
let editingInput = null;
/**
* Render spreadsheet from store
*/
function render() {
const tbody = window.app.dom.spreadsheetBody;
tbody.innerHTML = '';
// Get files from store (already filtered and sorted)
const files = window.app.modules.store.getDisplayFiles();
// Render rows
if (files.length === 0) {
const message = window.app.modules.store.getDisplayFiles().length === 0
? '<h3>No files to display</h3><p>Select one or more folders from the tree to view files</p>'
: '<h3>No files match filters</h3><p>Adjust or clear filters to see files</p>';
tbody.innerHTML = `
<tr>
<td colspan="10" class="spreadsheet-empty">
${message}
</td>
</tr>
`;
return;
}
files.forEach((file, index) => {
const row = createRow(file, index);
tbody.appendChild(row);
});
// Update UI
window.app.modules.app.updateStats();
updateSortIndicators();
// Calculate SHA256 if enabled
if (window.app.calculateSha256) {
calculateSha256ForAll();
}
}
/**
* Update sort indicators
*/
function updateSortIndicators() {
const sortColumns = window.app.modules.store.getSortColumns();
const headers = window.app.dom.spreadsheet.querySelectorAll('thead th');
headers.forEach(th => {
const indicator = th.querySelector('.sort-indicator');
if (!indicator) return;
const columnName = th.className.replace('col-', '');
const sortIndex = sortColumns.findIndex(s => s.column === columnName);
if (sortIndex >= 0) {
const sort = sortColumns[sortIndex];
const arrow = sort.direction === 'asc' ? '▲' : '▼';
const priority = sortColumns.length > 1 ? (sortIndex + 1) : '';
indicator.textContent = ` ${arrow}${priority}`;
indicator.style.display = 'inline';
} else {
indicator.textContent = '';
indicator.style.display = 'none';
}
});
}
/**
* Create a table row for a file
*/
function createRow(file, index) {
const row = document.createElement('tr');
row.dataset.index = index;
row.dataset.folderPath = file.folderPath; // Store folder path for highlighting
// Add state classes
if (file.isDirty) row.classList.add('modified');
if (file.error) row.classList.add('error');
// Highlight folder on hover
row.addEventListener('mouseenter', () => {
highlightFolder(file.folderPath);
});
row.addEventListener('mouseleave', () => {
clearFolderHighlight();
});
// Row number
row.appendChild(createCell('row-num', index + 1, false));
// Original filename — plain text (selectable/copyable, no link)
const originalCell = createCell('original', file.originalFilename, false);
row.appendChild(originalCell);
// Extension — hyperlink to open the file
const extCell = createCell('extension', '', false);
const extLink = document.createElement('a');
extLink.className = 'cell-link';
extLink.textContent = file.extension;
extLink.title = 'Click to open file';
extLink.style.cursor = 'pointer';
extLink.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
openFile(file);
});
extCell.appendChild(extLink);
row.appendChild(extCell);
// Parse original filename to get ZDDC components (always)
// Pass full filename (name + extension) so the regex can match the .ext suffix
const parsed = zddc.parseFilename(zddc.joinExtension(file.originalFilename, file.extension)) || {};
// Fill any empty fields from parsed filename (per-field, not all-or-nothing)
// Must happen before computeNewFilename so all fields are available
if (!file.trackingNumber) file.trackingNumber = parsed.trackingNumber || '';
if (!file.revision) file.revision = parsed.revision || '';
if (!file.status) file.status = parsed.status || '';
if (!file.title) file.title = parsed.title || '';
// New filename: show only if it would actually change the file
const computedFilename = file.manualFilename || computeNewFilename(file, index);
const originalFullName = zddc.joinExtension(file.originalFilename, file.extension);
const wouldChange = computedFilename !== originalFullName;
const newFilenameDisplay = wouldChange ? computedFilename : '';
const newFilenameCell = createEditableCell('newFilename', newFilenameDisplay, index);
// Use computedFilename (not newFilenameDisplay) for validation
const newFilename = computedFilename;
if (!file.manualFilename) {
newFilenameCell.classList.add('computed');
}
// Validate and show errors
const validation = window.app.modules.validator.validateFilename(newFilename);
if (!validation.isValid) {
newFilenameCell.classList.add('validation-error');
newFilenameCell.title = validation.errors.join('; ');
} else if (validation.warnings.length > 0) {
newFilenameCell.classList.add('validation-warning');
newFilenameCell.title = validation.warnings.join('; ');
}
// Only show action buttons if row is dirty
if (file.isDirty) {
const actions = document.createElement('span');
actions.className = 'inline-actions';
const saveBtn = document.createElement('button');
saveBtn.className = 'btn-inline btn-save';
saveBtn.textContent = '✓';
saveBtn.title = 'Save';
saveBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveFile(index);
});
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-inline btn-cancel';
cancelBtn.textContent = '✗';
cancelBtn.title = 'Clear all fields';
cancelBtn.addEventListener('click', (e) => {
e.stopPropagation();
cancelFile(index);
});
actions.appendChild(saveBtn);
actions.appendChild(cancelBtn);
newFilenameCell.appendChild(actions);
}
row.appendChild(newFilenameCell);
// For each field: use file value (already populated above) for display
const displayTracking = file.trackingNumber || '';
const displayRevision = file.revision || '';
const displayStatus = file.status || '';
const displayTitle = file.title || '';
const trackingCell = createEditableCell('trackingNumber', displayTracking, index);
const revisionCell = createEditableCell('revision', displayRevision, index);
const statusCell = createEditableCell('status', displayStatus, index);
const titleCell = createEditableCell('title', displayTitle, index);
// Gray = field value matches what the original filename parses to (no change)
// Blue = field value differs from the parsed original (would produce a different filename)
if (displayTracking === (parsed.trackingNumber || '')) trackingCell.classList.add('auto-populated');
else trackingCell.classList.add('field-changed');
if (displayRevision === (parsed.revision || '')) revisionCell.classList.add('auto-populated');
else revisionCell.classList.add('field-changed');
if (displayStatus === (parsed.status || '')) statusCell.classList.add('auto-populated');
else statusCell.classList.add('field-changed');
if (displayTitle === (parsed.title || '')) titleCell.classList.add('auto-populated');
else titleCell.classList.add('field-changed');
row.appendChild(trackingCell);
row.appendChild(revisionCell);
row.appendChild(statusCell);
row.appendChild(titleCell);
// SHA256 (if enabled)
if (window.app.calculateSha256) {
const sha256Cell = createCell('sha256', file.sha256 || 'calculating...', false);
if (!file.sha256) {
sha256Cell.classList.add('sha256-calculating');
}
row.appendChild(sha256Cell);
}
return row;
}
/**
* Create a table cell
*/
function createCell(className, content, editable = false) {
const td = document.createElement('td');
td.className = `col-${className}`;
td.textContent = content;
return td;
}
/**
* Create an editable cell
*/
function createEditableCell(columnName, value, rowIndex) {
const td = document.createElement('td');
td.className = `col-${columnName} cell-editable`;
td.textContent = value;
// Double-click to edit
td.addEventListener('dblclick', (e) => {
e.stopPropagation();
startEditing(td, columnName, rowIndex);
});
return td;
}
/**
* Start editing a cell
*/
function startEditing(cell, columnName, rowIndex) {
// Cancel any existing edit
if (editingCell) {
cancelEditing();
}
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (!file) return;
const currentValue = file[columnName] || '';
// Clear any cell selection
if (window.app.modules.selection) {
window.app.modules.selection.clearSelection();
}
// Store references
editingCell = { cell, columnName, rowIndex, originalValue: currentValue };
// Save original content and make cell contenteditable
cell.dataset.originalContent = cell.innerHTML;
cell.contentEditable = 'true';
cell.classList.add('editing');
editingInput = cell;
// Set content (text only for editing)
cell.textContent = currentValue;
// Focus and select all
cell.focus();
// Select all text — guard against cell being detached from document
// (can happen if a re-render fires between dblclick and this point)
if (document.contains(cell)) {
const range = document.createRange();
range.selectNodeContents(cell);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
// Event listeners
cell.addEventListener('blur', handleBlur, { once: true });
cell.addEventListener('keydown', handleKeyDown);
}
/**
* Handle blur event
*/
function handleBlur() {
// Small delay to allow click events to fire first
setTimeout(() => finishEditing(), 100);
}
/**
* Handle keydown in contenteditable
*/
function handleKeyDown(e) {
if (e.key === 'Enter') {
// Enter exits edit mode
e.preventDefault();
finishEditing();
} else if (e.key === 'Escape') {
// Escape undoes and exits edit mode
e.preventDefault();
cancelEditing();
} else if (e.key === 'Tab') {
// Tab/Shift+Tab moves to next/prev cell
e.preventDefault();
const { rowIndex, columnName } = editingCell || {};
const shiftKey = e.shiftKey;
finishEditingQuiet(); // Don't trigger store update
if (rowIndex !== undefined) {
if (shiftKey) {
moveToPreviousCell(rowIndex, columnName);
} else {
moveToNextCell(rowIndex, columnName);
}
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const { rowIndex, columnName } = editingCell || {};
finishEditingQuiet();
if (rowIndex !== undefined) {
moveUpRow(rowIndex, columnName);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const { rowIndex, columnName } = editingCell || {};
finishEditingQuiet();
if (rowIndex !== undefined) {
moveDownRow(rowIndex, columnName);
}
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
// Allow normal cursor movement within cell
e.stopPropagation();
}
}
/**
* Finish editing and save value
*/
function finishEditing() {
if (!editingCell || !editingInput) return;
const { cell, columnName, rowIndex } = editingCell;
const newValue = editingInput.textContent.trim();
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (!file) return;
const oldValue = file[columnName] || '';
// Remove contenteditable
editingInput.contentEditable = 'false';
editingInput.classList.remove('editing');
editingInput.removeEventListener('keydown', handleKeyDown);
// Update file data if changed
if (newValue !== oldValue) {
// Special handling for newFilename column
if (columnName === 'newFilename') {
if (newValue) {
window.app.modules.store.updateFileField(rowIndex, 'manualFilename', newValue);
} else {
window.app.modules.store.updateFile(rowIndex, { manualFilename: null });
}
} else {
window.app.modules.store.updateFileField(rowIndex, columnName, newValue);
}
const updatedFile = window.app.modules.store.getDisplayFiles()[rowIndex];
validateFile(updatedFile);
}
editingCell = null;
editingInput = null;
}
/**
* Finish editing without triggering store update (for Tab/Arrow navigation)
*/
function finishEditingQuiet() {
if (!editingCell || !editingInput) return;
const { columnName, rowIndex } = editingCell;
const newValue = editingInput.textContent.trim();
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
// Remove contenteditable
editingInput.contentEditable = 'false';
editingInput.classList.remove('editing');
editingInput.removeEventListener('keydown', handleKeyDown);
// Update file object directly (no store notification)
if (file) {
if (columnName === 'newFilename') {
file.manualFilename = newValue || null;
} else {
file[columnName] = newValue;
}
file.isDirty = true;
}
editingCell = null;
editingInput = null;
}
/**
* Cancel editing without saving
*/
function cancelEditing() {
if (!editingCell) return;
const { rowIndex } = editingCell;
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (!file) return;
// Clear editing state
editingCell = null;
editingInput = null;
// Re-render the row
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex}"]`);
if (row) {
const newRow = createRow(file, rowIndex);
row.replaceWith(newRow);
}
}
/**
* Move to next editable cell
*/
function moveToNextCell(rowIndex, currentColumn) {
const columns = ['newFilename', 'trackingNumber', 'revision', 'status', 'title'];
const currentIndex = columns.indexOf(currentColumn);
if (currentIndex < columns.length - 1) {
// Next column in same row
const nextColumn = columns[currentIndex + 1];
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex}"]`);
const nextCell = row.querySelector(`.col-${nextColumn}`);
if (nextCell) {
startEditing(nextCell, nextColumn, rowIndex);
}
} else if (rowIndex < window.app.modules.store.getDisplayFiles().length - 1) {
// First column of next row
const nextColumn = columns[0];
const nextRow = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex + 1}"]`);
const nextCell = nextRow.querySelector(`.col-${nextColumn}`);
if (nextCell) {
startEditing(nextCell, nextColumn, rowIndex + 1);
}
}
}
/**
* Move to previous editable cell
*/
function moveToPreviousCell(rowIndex, currentColumn) {
const columns = ['newFilename', 'trackingNumber', 'revision', 'status', 'title'];
const currentIndex = columns.indexOf(currentColumn);
if (currentIndex > 0) {
// Previous column in same row
const prevColumn = columns[currentIndex - 1];
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex}"]`);
const prevCell = row.querySelector(`.col-${prevColumn}`);
if (prevCell) {
startEditing(prevCell, prevColumn, rowIndex);
}
} else if (rowIndex > 0) {
// Last column of previous row
const prevColumn = columns[columns.length - 1];
const prevRow = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex - 1}"]`);
const prevCell = prevRow.querySelector(`.col-${prevColumn}`);
if (prevCell) {
startEditing(prevCell, prevColumn, rowIndex - 1);
}
}
}
/**
* Move up one row, same column
*/
function moveUpRow(rowIndex, currentColumn) {
if (rowIndex > 0) {
const prevRow = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex - 1}"]`);
const cell = prevRow.querySelector(`.col-${currentColumn}`);
if (cell) {
startEditing(cell, currentColumn, rowIndex - 1);
}
}
}
/**
* Move down one row, same column
*/
function moveDownRow(rowIndex, currentColumn) {
if (rowIndex < window.app.modules.store.getDisplayFiles().length - 1) {
const nextRow = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex + 1}"]`);
const cell = nextRow.querySelector(`.col-${currentColumn}`);
if (cell) {
startEditing(cell, currentColumn, rowIndex + 1);
}
}
}
function computeNewFilename(file) {
return window.app.modules.utils.computeNewFilename(file);
}
/**
* Validate a file
*/
function validateFile(file) {
const newFilename = computeNewFilename(file, 0);
const validation = window.app.modules.validator.validateFilename(newFilename);
file.validation = validation;
file.error = !validation.isValid;
}
/**
* Open file in new tab
*/
async function openFile(file) {
try {
let blob;
if (file.isVirtual) {
// Virtual file from ZIP - get from cache
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
if (!cached) throw new Error('ZIP not found in cache');
const zipEntry = cached.zip.file(file.zipEntryPath);
if (!zipEntry) throw new Error('File not found in ZIP');
const arrayBuffer = await zipEntry.async('arraybuffer');
const mimeType = getMimeType(file.extension);
blob = new Blob([arrayBuffer], { type: mimeType });
} else {
blob = await file.handle.getFile();
}
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// Clean up URL after a delay
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch (err) {
console.error('Error opening file:', err);
alert('Cannot open file: ' + err.message);
}
}
function getMimeType(extension) {
return window.app.modules.utils.getMimeType(extension);
}
/**
* Save a single file
*/
async function saveFile(index, skipValidation = false) {
const files = window.app.modules.store.getDisplayFiles();
const file = files[index];
if (!file.isDirty) {
return;
}
// Virtual files (from ZIPs) cannot be renamed - must extract first
if (file.isVirtual) {
alert('Cannot rename files inside ZIP archives.\nExtract the ZIP first to rename files.');
return;
}
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${index}"]`);
if (!row) {
console.error(`Row not found for index ${index}`);
return;
}
row.classList.add('saving');
try {
const newFilename = computeNewFilename(file, index);
const currentFilename = zddc.joinExtension(file.originalFilename, file.extension);
// Check if already has correct name
if (currentFilename === newFilename) {
row.classList.remove('saving');
// Just clear dirty flag and fields
file.isDirty = false;
file.error = false;
delete file.manualFilename;
file.trackingNumber = '';
file.revision = '';
file.status = '';
file.title = '';
const newRow = createRow(file, index);
row.replaceWith(newRow);
window.app.modules.app.updateStats();
return;
}
// Validate filename
if (!skipValidation) {
const validation = window.app.modules.validator.validateFilename(newFilename);
if (!validation.isValid) {
const errors = validation.errors.join('\n');
const confirmed = confirm(
`⚠️ Warning: Filename is not ZDDC compliant!\n\n` +
`Errors:\n${errors}\n\n` +
`Current filename: ${newFilename}\n\n` +
`Do you want to save it anyway?`
);
if (!confirmed) {
row.classList.remove('saving');
return;
}
}
}
// Request write permission for the folder
const folderPermission = await file.folderHandle.queryPermission({ mode: 'readwrite' });
if (folderPermission !== 'granted') {
const granted = await file.folderHandle.requestPermission({ mode: 'readwrite' });
if (granted !== 'granted') {
throw new Error('Write permission denied');
}
}
// Rename by copying to new name and deleting old (more reliable than move)
const oldFilename = zddc.joinExtension(file.originalFilename, file.extension);
try {
// Get fresh handle for old file
const oldHandle = await file.folderHandle.getFileHandle(oldFilename);
// Read the file content
const fileData = await oldHandle.getFile();
// Create new file with new name
const newHandle = await file.folderHandle.getFileHandle(newFilename, { create: true });
const writable = await newHandle.createWritable();
await writable.write(fileData);
await writable.close();
// Delete old file
await file.folderHandle.removeEntry(oldFilename);
// Update file handle
file.handle = newHandle;
} catch (err) {
console.error(`Failed to rename file:`, err);
throw err;
}
// Update file data directly (don't trigger store notification during batch save)
file.originalFilename = zddc.splitExtension(newFilename).name;
file.isDirty = false;
file.error = false;
file.manualFilename = null;
file.trackingNumber = '';
file.revision = '';
file.status = '';
file.title = '';
file.autoPopulated = false;
// Update row UI
row.classList.remove('saving');
const newRow = createRow(file, index);
row.replaceWith(newRow);
} catch (err) {
console.error('Error saving file:', err);
file.error = true;
file.errorMessage = err.message;
row.classList.remove('saving');
row.classList.add('error');
// Re-throw so caller can handle
throw err;
}
}
/**
* Cancel/Clear all fields for a single file
*/
function cancelFile(index) {
// Clear all fields through store
window.app.modules.store.updateFile(index, {
trackingNumber: '',
revision: '',
status: '',
title: '',
manualFilename: null,
isDirty: false,
error: false,
validation: null,
autoPopulated: false
});
}
/**
* Save all modified files (only ZDDC-compliant ones)
*/
async function saveAllFiles() {
const files = window.app.modules.store.getDisplayFiles();
const modifiedFiles = files
.map((file, index) => ({ file, index }))
.filter(({ file }) => file.isDirty);
if (modifiedFiles.length === 0) {
alert('No modified files to save.');
return;
}
let successCount = 0;
let skippedCount = 0;
let errorCount = 0;
const errors = [];
const skipped = [];
for (let i = 0; i < modifiedFiles.length; i++) {
const { file, index } = modifiedFiles[i];
try {
// Add small delay between operations to prevent race conditions
if (i > 0) {
await new Promise(resolve => setTimeout(resolve, 200));
}
// Validate before saving
const newFilename = computeNewFilename(file, index);
const currentFilename = zddc.joinExtension(file.originalFilename, file.extension);
const validation = window.app.modules.validator.validateFilename(newFilename);
if (!validation.isValid) {
// Skip non-compliant files in Save All
skippedCount++;
skipped.push(`${zddc.joinExtension(file.originalFilename, file.extension)}: ${validation.errors[0]}`);
continue;
}
// Check if already has correct name
if (currentFilename === newFilename) {
// Just clear dirty flag
file.isDirty = false;
file.error = false;
delete file.manualFilename;
file.trackingNumber = '';
file.revision = '';
file.status = '';
file.title = '';
successCount++;
continue;
}
// Save with validation already done - ensure properly awaited
try {
await saveFile(index, true);
successCount++;
} catch (saveErr) {
console.error(`Error saving file ${index}:`, saveErr);
errorCount++;
errors.push(`${zddc.joinExtension(file.originalFilename, file.extension)}: ${saveErr.message}`);
// Add delay after errors to let filesystem stabilize
await new Promise(resolve => setTimeout(resolve, 300));
}
} catch (err) {
console.error(`Error processing file ${index}:`, err);
errorCount++;
errors.push(`${file.originalFilename}${file.extension}: ${err.message}`);
}
}
// Trigger store notification to update UI after all saves
window.app.modules.store.notify('files');
let message = `Saved ${successCount} compliant file(s).`;
if (skippedCount > 0) {
message += `\n\n⚠️ Skipped ${skippedCount} non-compliant file(s):`;
message += `\n${skipped.slice(0, 3).join('\n')}`;
if (skipped.length > 3) {
message += `\n... and ${skipped.length - 3} more`;
}
message += `\n\nUse individual save buttons (✓) to save non-compliant files.`;
}
if (errorCount > 0) {
message += `\n\n${errorCount} error(s):`;
message += `\n${errors.slice(0, 3).join('\n')}`;
if (errors.length > 3) {
message += `\n... and ${errors.length - 3} more`;
}
}
alert(message);
}
/**
* Cancel all changes
*/
function cancelAllChanges() {
const files = window.app.modules.store.getDisplayFiles();
files.forEach((file, index) => {
if (file.isDirty) {
cancelFile(index);
}
});
}
/**
* Calculate SHA256 for all files
*/
async function calculateSha256ForAll() {
const files = window.app.modules.store.getDisplayFiles();
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.sha256) {
calculateSha256(file, i);
}
}
}
/**
* Calculate SHA256 for a single file
*/
async function calculateSha256(file, index) {
try {
let hashHex;
if (file.isVirtual) {
// Virtual file from ZIP
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
if (!cached) throw new Error('ZIP not found in cache');
const zipEntry = cached.zip.file(file.zipEntryPath);
if (!zipEntry) throw new Error('File not found in ZIP');
const buffer = await zipEntry.async('arraybuffer');
hashHex = await zddc.crypto.sha256Hex(buffer);
} else {
const fileObj = await file.handle.getFile();
hashHex = await zddc.crypto.sha256File(fileObj);
}
file.sha256 = hashHex;
// Update cell
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${index}"]`);
if (row) {
const sha256Cell = row.querySelector('.col-sha256');
if (sha256Cell) {
sha256Cell.textContent = hashHex.substring(0, 16) + '...';
sha256Cell.title = hashHex;
sha256Cell.classList.remove('sha256-calculating');
}
}
} catch (err) {
console.error('Error calculating SHA256:', err);
}
}
/**
* Highlight folder in tree when hovering over file
*/
function highlightFolder(folderPath) {
if (!folderPath) return;
// Find folder in tree
const folderTree = document.getElementById('folderTree');
if (!folderTree) return;
// Find the folder item by data-path attribute
const folderItem = folderTree.querySelector(`[data-path="${folderPath}"]`);
if (!folderItem) return;
// Add highlight class
folderItem.classList.add('folder-hover-highlight');
// Scroll into view if autoscroll is enabled
const autoScrollCheckbox = document.getElementById('autoScrollCheckbox');
if (autoScrollCheckbox && autoScrollCheckbox.checked) {
folderItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
/**
* Clear folder highlight
*/
function clearFolderHighlight() {
const folderTree = document.getElementById('folderTree');
if (!folderTree) return;
// Remove all highlights
const highlighted = folderTree.querySelectorAll('.folder-hover-highlight');
highlighted.forEach(el => el.classList.remove('folder-hover-highlight'));
}
/**
* Initialize spreadsheet - subscribe to store
*/
function init() {
// Subscribe to store changes (only call this after DOM is ready)
window.app.modules.store.on('files', render);
}
// Export module
window.app.modules.spreadsheet = {
init,
render,
computeNewFilename,
saveAllFiles,
cancelAllChanges,
cancelEditing
};
})();
/**
* Selection Module
* Handles Excel-style cell selection and copy/paste
*/
(function() {
'use strict';
let selectionStart = null;
let selectionEnd = null;
let isSelecting = false;
let initialized = false;
let autoScrollInterval = null;
let lastMouseY = 0;
let startMouseX = 0;
let startMouseY = 0;
let dragDistance = 0;
/**
* Initialize selection handlers
*/
function init() {
// Only initialize once
if (initialized) return;
initialized = true;
const table = window.app.dom.spreadsheet;
// Make table focusable so clipboard events fire
if (!table.hasAttribute('tabindex')) {
table.setAttribute('tabindex', '-1');
table.style.outline = 'none';
}
// Mouse down on cell - start selection
table.addEventListener('mousedown', handleMouseDown);
// Mouse move - extend selection
document.addEventListener('mousemove', handleMouseMove);
// Mouse up - end selection
document.addEventListener('mouseup', handleMouseUp);
// Selectstart handler - prevent only when dragging (multi-cell selection)
document.addEventListener('selectstart', (e) => {
if (isSelecting && dragDistance > 4) {
e.preventDefault();
}
});
// Copy/paste handlers
document.addEventListener('copy', handleCopy);
document.addEventListener('paste', handlePaste);
document.addEventListener('cut', handleCut);
}
/**
* Handle mouse down on cell
*/
function handleMouseDown(e) {
const cell = e.target.closest('td');
if (!cell) return;
// Ignore if clicking action buttons
if (e.target.closest('.inline-actions')) return;
// Ignore if cell is being edited (contenteditable)
if (cell.isContentEditable || cell.classList.contains('editing')) return;
// Don't start selection if double-clicking to edit
if (e.detail === 2) return;
const row = cell.closest('tr');
if (!row) return;
const rowIndex = parseInt(row.dataset.index);
const colIndex = Array.from(row.children).indexOf(cell);
// Shift+Click: extend selection from existing start to clicked cell
if (e.shiftKey && selectionStart) {
selectionEnd = { row: rowIndex, col: colIndex };
updateSelection();
updateButtonStates();
e.preventDefault();
return;
}
// Clear previous selection
clearSelection();
// Start new selection
selectionStart = { row: rowIndex, col: colIndex };
selectionEnd = { row: rowIndex, col: colIndex };
isSelecting = true;
dragDistance = 0;
startMouseX = e.clientX;
startMouseY = e.clientY;
// Highlight cell
updateSelection();
updateButtonStates();
// Focus the table so clipboard events (Ctrl+V) fire
window.app.dom.spreadsheet.focus();
// Only prevent default for shift-click (extending selection)
// Single click should allow text selection to work normally
if (e.shiftKey) {
e.preventDefault();
}
}
/**
* Handle mouse move during selection
*/
function handleMouseMove(e) {
if (!isSelecting) return;
// Store mouse position for auto-scroll
lastMouseY = e.clientY;
// Track drag distance from mousedown
const dx = e.clientX - startMouseX;
const dy = e.clientY - startMouseY;
dragDistance = Math.sqrt(dx*dx + dy*dy);
const cell = e.target.closest('td');
if (!cell) return;
const row = cell.closest('tr');
if (!row) return;
const rowIndex = parseInt(row.dataset.index);
const colIndex = Array.from(row.children).indexOf(cell);
if (rowIndex === undefined || colIndex === -1) return;
// Update selection end
selectionEnd = { row: rowIndex, col: colIndex };
// Highlight selected cells
updateSelection();
// Start auto-scroll if not already running
if (!autoScrollInterval) {
startAutoScroll();
}
}
/**
* Start continuous auto-scroll
*/
function startAutoScroll() {
const scrollThreshold = 50; // pixels from edge
const scrollSpeed = 5; // pixels per frame
const scroll = () => {
if (!isSelecting) {
autoScrollInterval = null;
return;
}
const viewport = document.querySelector('.spreadsheet-pane');
if (!viewport) {
autoScrollInterval = null;
return;
}
const rect = viewport.getBoundingClientRect();
// Determine scroll direction based on last mouse position
if (lastMouseY > rect.bottom - scrollThreshold) {
viewport.scrollTop += scrollSpeed; // Scroll down
} else if (lastMouseY < rect.top + scrollThreshold) {
viewport.scrollTop -= scrollSpeed; // Scroll up
}
// Continue scrolling
autoScrollInterval = requestAnimationFrame(scroll);
};
autoScrollInterval = requestAnimationFrame(scroll);
}
/**
* Handle mouse up - end selection
*/
function handleMouseUp(e) {
isSelecting = false;
dragDistance = 0;
// Stop auto-scrolling
if (autoScrollInterval) {
cancelAnimationFrame(autoScrollInterval);
autoScrollInterval = null;
}
updateButtonStates();
}
/**
* Update visual selection highlighting
*/
function updateSelection() {
if (!selectionStart || !selectionEnd) return;
// Clear all previous highlights
document.querySelectorAll('.selected-cell').forEach(cell => {
cell.classList.remove('selected-cell');
});
// Calculate selection bounds
const minRow = Math.min(selectionStart.row, selectionEnd.row);
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
// Highlight selected cells
const tbody = window.app.dom.spreadsheetBody;
const rows = tbody.querySelectorAll('tr');
for (let r = minRow; r <= maxRow; r++) {
const row = rows[r];
if (!row) continue;
const cells = row.children;
for (let c = minCol; c <= maxCol; c++) {
const cell = cells[c];
if (cell) {
cell.classList.add('selected-cell');
}
}
}
// Emit rowfocused event for preview pane
emitRowFocused(minRow);
}
/**
* Emit row focused event for preview pane
*/
function emitRowFocused(rowIndex) {
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (file) {
const event = new CustomEvent('rowfocused', {
detail: { rowIndex, file }
});
document.dispatchEvent(event);
}
}
/**
* Clear selection
*/
function clearSelection() {
selectionStart = null;
selectionEnd = null;
document.querySelectorAll('.selected-cell').forEach(cell => {
cell.classList.remove('selected-cell');
});
updateButtonStates();
}
/**
* Check if there is an active selection
*/
function hasSelection() {
return selectionStart !== null && selectionEnd !== null;
}
/**
* Update Copy/Paste button enabled states
*/
function updateButtonStates() {
const copyBtn = document.getElementById('copyBtn');
const pasteBtn = document.getElementById('pasteBtn');
const active = hasSelection();
if (copyBtn) copyBtn.disabled = !active;
if (pasteBtn) pasteBtn.disabled = !active;
}
/**
* Get column headers for selected columns
*/
function getColumnHeaders(minCol, maxCol) {
const headerCells = window.app.dom.spreadsheet.querySelectorAll('thead th');
const headers = [];
for (let c = minCol; c <= maxCol; c++) {
const th = headerCells[c];
if (th) {
// Get the text content, excluding filter inputs
const text = th.childNodes[0]?.textContent?.trim() || th.textContent.trim();
headers.push(text);
} else {
headers.push('');
}
}
return headers;
}
/**
* Check if first row looks like a header row
*/
function isHeaderRow(row) {
const headerPatterns = ['#', 'Original', 'Ext', 'New', 'Tracking', 'Rev', 'Status', 'Title', 'SHA256'];
return row.some(cell => headerPatterns.includes(cell.trim()));
}
/**
* Convert 2D array of rows to an HTML table string.
* Excel prefers text/html over text/plain, so providing a
* proper <table> ensures cell boundaries are preserved.
*/
function rowsToHtml(allRows) {
const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const headerRow = allRows[0];
const dataRows = allRows.slice(1);
let html = '<table>';
html += '<tr>' + headerRow.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>';
for (const row of dataRows) {
html += '<tr>' + row.map(c => '<td>' + esc(c) + '</td>').join('') + '</tr>';
}
html += '</table>';
return html;
}
/**
* Handle copy event
*/
function handleCopy(e) {
if (!selectionStart || !selectionEnd) return;
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
// Get column headers for selected range
const headers = getColumnHeaders(minCol, maxCol);
const data = getSelectionData();
if (!data) return;
// Prepend header row
const allRows = [headers, ...data];
// Convert to TSV and HTML table
const tsv = allRows.map(row => row.join('\t')).join('\n');
e.clipboardData.setData('text/plain', tsv);
e.clipboardData.setData('text/html', rowsToHtml(allRows));
e.preventDefault();
}
/**
* Handle cut event
*/
function handleCut(e) {
if (!selectionStart || !selectionEnd) return;
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
// Get column headers for selected range
const headers = getColumnHeaders(minCol, maxCol);
const data = getSelectionData();
if (!data) return;
// Prepend header row
const allRows = [headers, ...data];
// Convert to TSV
const tsv = allRows.map(row => row.join('\t')).join('\n');
e.clipboardData.setData('text/plain', tsv);
e.clipboardData.setData('text/html', rowsToHtml(allRows));
// Clear selected cells (only editable ones)
clearSelectionData();
e.preventDefault();
}
/**
* Handle paste event
*/
function handlePaste(e) {
// Don't intercept paste in input fields
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
return;
}
if (!selectionStart) return;
const tsv = e.clipboardData.getData('text/plain');
if (!tsv) return;
e.preventDefault();
executePaste(tsv);
}
/**
* Execute paste from TSV string into the selected range.
* - If pasted cols > selected cols, right-align (user likely copied all but only pastes back editable cols).
* - If pasted data includes the first two columns (#, Original), validate they match.
* - If pasted row count != selected row count, abort with error.
*/
function executePaste(tsv) {
if (!selectionStart || !selectionEnd) return;
// Parse TSV
let rows = tsv.split('\n').map(row => row.split('\t'));
// Filter out empty trailing rows
while (rows.length > 0 && rows[rows.length - 1].every(cell => !cell.trim())) {
rows.pop();
}
if (rows.length === 0) return;
// Check if first row is a header row and skip it
if (rows.length > 1 && isHeaderRow(rows[0])) {
rows = rows.slice(1);
}
if (rows.length === 0) return;
// Selection bounds
const minRow = Math.min(selectionStart.row, selectionEnd.row);
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
const selectedRowCount = maxRow - minRow + 1;
const selectedColCount = maxCol - minCol + 1;
const pastedColCount = rows[0].length;
// Row count validation: must match
if (rows.length !== selectedRowCount) {
alert(`Paste aborted: row count mismatch.\n` +
`Selected ${selectedRowCount} row(s), but clipboard has ${rows.length} row(s).`);
return;
}
// Determine column offset for right-alignment
let colOffset = 0;
if (pastedColCount > selectedColCount) {
colOffset = pastedColCount - selectedColCount;
}
// Get column names from header
const headerCells = window.app.dom.spreadsheet.querySelectorAll('thead th');
const columnNames = Array.from(headerCells).map(th => {
const match = th.className.match(/col-(\w+)/);
return match ? match[1] : '';
});
// If pasted data includes first two columns (row-num, original), validate they match
if (colOffset === 0 && pastedColCount >= selectedColCount) {
// Check if pasted range starts at col 0 or col 1 (row-num or original)
const files = window.app.modules.store.getDisplayFiles();
const startsAtRowNum = (minCol === 0);
const startsAtOriginal = (minCol === 1);
if (startsAtRowNum || startsAtOriginal) {
for (let r = 0; r < rows.length; r++) {
const targetRowIndex = minRow + r;
const file = files[targetRowIndex];
if (!file) continue;
if (startsAtRowNum) {
// Validate col 0 = row number, col 1 = original filename
const expectedNum = String(targetRowIndex + 1);
const pastedNum = rows[r][0]?.trim();
const pastedOriginal = rows[r][1]?.trim();
if (pastedNum && pastedNum !== expectedNum) {
alert(`Paste aborted: row number mismatch at row ${targetRowIndex + 1}.\n` +
`Expected "${expectedNum}", got "${pastedNum}".\n` +
`Data may be shuffled.`);
return;
}
if (pastedOriginal && pastedOriginal !== file.originalFilename) {
alert(`Paste aborted: filename mismatch at row ${targetRowIndex + 1}.\n` +
`Expected "${file.originalFilename}", got "${pastedOriginal}".\n` +
`Data may be shuffled.`);
return;
}
} else if (startsAtOriginal) {
// Validate col 0 of paste = original filename
const pastedOriginal = rows[r][0]?.trim();
if (pastedOriginal && pastedOriginal !== file.originalFilename) {
alert(`Paste aborted: filename mismatch at row ${targetRowIndex + 1}.\n` +
`Expected "${file.originalFilename}", got "${pastedOriginal}".\n` +
`Data may be shuffled.`);
return;
}
}
}
}
}
// Editable columns
const editableColumns = ['newFilename', 'trackingNumber', 'revision', 'status', 'title'];
const files = window.app.modules.store.getDisplayFiles();
let updatedCount = 0;
for (let r = 0; r < rows.length; r++) {
const targetRowIndex = minRow + r;
if (targetRowIndex >= files.length) continue;
const file = files[targetRowIndex];
if (!file) continue;
const rowData = rows[r];
for (let c = 0; c < selectedColCount; c++) {
const pasteIdx = c + colOffset; // right-align: skip leading pasted cols
if (pasteIdx >= rowData.length) continue;
const targetColIndex = minCol + c;
if (targetColIndex >= columnNames.length) continue;
const columnName = columnNames[targetColIndex];
if (!editableColumns.includes(columnName)) continue;
const value = rowData[pasteIdx]?.trim() || '';
if (columnName === 'newFilename') {
if (value) {
file.manualFilename = value;
} else {
delete file.manualFilename;
}
} else {
file[columnName] = value;
if (file.manualFilename) {
delete file.manualFilename;
}
}
file.isDirty = true;
file.autoPopulated = false;
updatedCount++;
}
}
// Re-render and restore selection
if (updatedCount > 0) {
window.app.modules.spreadsheet.render();
// Restore selection highlight
updateSelection();
showToast(`Pasted ${updatedCount} cell(s)`, 'success');
}
}
/**
* Show a brief toast notification
*/
function showToast(message, type) {
if (window.app.modules.excel && window.app.modules.excel.showToast) {
window.app.modules.excel.showToast(message, type);
return;
}
// Fallback: simple toast
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = 'position:fixed;bottom:20px;right:20px;padding:8px 16px;' +
'background:' + (type === 'success' ? '#28a745' : '#dc3545') + ';color:#fff;' +
'border-radius:4px;z-index:9999;font-size:14px;';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
/**
* Get data from selected cells
*/
function getSelectionData() {
if (!selectionStart || !selectionEnd) return null;
const minRow = Math.min(selectionStart.row, selectionEnd.row);
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
const data = [];
const tbody = window.app.dom.spreadsheetBody;
const rows = tbody.querySelectorAll('tr');
for (let r = minRow; r <= maxRow; r++) {
const row = rows[r];
if (!row) continue;
const rowData = [];
const cells = row.children;
for (let c = minCol; c <= maxCol; c++) {
const cell = cells[c];
if (cell) {
rowData.push(cell.textContent.trim());
} else {
rowData.push('');
}
}
data.push(rowData);
}
return data;
}
/**
* Clear data from selected cells (only editable ones)
*/
function clearSelectionData() {
if (!selectionStart || !selectionEnd) return;
const minRow = Math.min(selectionStart.row, selectionEnd.row);
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
const tbody = window.app.dom.spreadsheetBody;
const rows = tbody.querySelectorAll('tr');
// Get column names from header
const headerCells = window.app.dom.spreadsheet.querySelectorAll('thead th');
const columnNames = Array.from(headerCells).map(th => {
const className = th.className.replace('col-', '');
return className;
});
for (let r = minRow; r <= maxRow; r++) {
const row = rows[r];
if (!row) continue;
const rowIndex = parseInt(row.dataset.index);
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (!file) continue;
const cells = row.children;
for (let c = minCol; c <= maxCol; c++) {
const cell = cells[c];
if (!cell || !cell.classList.contains('cell-editable')) continue;
const columnName = columnNames[c];
// Clear the data
if (columnName === 'newFilename') {
delete file.manualFilename;
} else {
file[columnName] = '';
}
file.isDirty = true;
}
}
// Re-render
window.app.modules.spreadsheet.render();
}
/**
* Copy selection to clipboard via button click
*/
function doCopy() {
if (!selectionStart || !selectionEnd) return;
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
const headers = getColumnHeaders(minCol, maxCol);
const data = getSelectionData();
if (!data) return;
const allRows = [headers, ...data];
const tsv = allRows.map(row => row.join('\t')).join('\n');
const html = rowsToHtml(allRows);
// Write both plain text and HTML to clipboard
const htmlBlob = new Blob([html], { type: 'text/html' });
const textBlob = new Blob([tsv], { type: 'text/plain' });
const item = new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob });
navigator.clipboard.write([item]).then(() => {
showToast(`Copied ${data.length} row(s)`, 'success');
}).catch(err => {
// Fallback to plain text if ClipboardItem not supported
navigator.clipboard.writeText(tsv).then(() => {
showToast(`Copied ${data.length} row(s)`, 'success');
}).catch(err2 => {
console.error('Copy failed:', err2);
});
});
}
/**
* Paste from clipboard via button click
*/
function doPaste() {
if (!selectionStart || !selectionEnd) return;
navigator.clipboard.readText().then(tsv => {
if (tsv) executePaste(tsv);
}).catch(err => {
console.error('Paste failed:', err);
alert('Cannot read clipboard. Use Ctrl+V instead, or grant clipboard permission.');
});
}
// Export module
window.app.modules.selection = {
init,
clearSelection,
hasSelection,
doCopy,
doPaste
};
})();
/**
* Preview Module
* Opens file preview in a separate popup window
*/
(function() {
'use strict';
let currentBlobUrl = null;
let currentFile = null;
let currentRowIndex = null;
let previewWindow = null;
// Use shared extension lists from window.zddc.preview where possible
const IMAGE_EXTENSIONS = zddc.preview.IMAGE_EXTENSIONS;
const TIFF_EXTENSIONS = zddc.preview.TIFF_EXTENSIONS;
const TEXT_EXTENSIONS = zddc.preview.TEXT_EXTENSIONS;
const PDF_EXTENSIONS = ['pdf'];
const ZIP_EXTENSIONS = ['zip'];
// Lazily load a script from CDN — delegates to shared cache.
const loadLibrary = zddc.preview.loadLibrary;
/**
* Initialize preview module
*/
function init() {
// Listen for row focused events from selection module
document.addEventListener('rowfocused', handleRowFocused);
// Set up toggle button to open/close preview window
const toggleBtn = document.getElementById('togglePreviewBtn');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
if (previewWindow && !previewWindow.closed) {
// Close preview window
previewWindow.close();
previewWindow = null;
toggleBtn.classList.remove('preview-active');
} else if (currentFile) {
openPreviewWindow(currentFile);
toggleBtn.classList.add('preview-active');
}
});
}
}
/**
* Handle row focused event
*/
function handleRowFocused(e) {
const { rowIndex, file } = e.detail;
currentRowIndex = rowIndex;
if (file && file !== currentFile) {
currentFile = file;
// Update preview window if open
if (previewWindow && !previewWindow.closed) {
openPreviewWindow(file);
}
}
}
/**
* Open preview in a separate popup window
*/
async function openPreviewWindow(file) {
if (!file) return;
currentFile = file;
try {
const blob = await getFileBlob(file);
// Clean up previous blob URL
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
}
currentBlobUrl = URL.createObjectURL(blob);
const fileName = zddc.joinExtension(file.originalFilename, file.extension);
// Build preview HTML with toolbar
const previewHtml = `
<!DOCTYPE html>
<html>
<head>
<title>${escapeHtml(fileName)} - Preview</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
display: flex;
flex-direction: column;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.toolbar h1 {
flex: 1;
font-size: 0.95rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
}
.btn:hover { background: #e8e8e8; }
iframe, img {
flex: 1;
width: 100%;
border: none;
}
img {
object-fit: contain;
background: #f0f0f0;
}
pre {
flex: 1;
padding: 1rem;
overflow: auto;
background: #fafafa;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.unsupported {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
}
.unsupported .icon { font-size: 3rem; margin-bottom: 1rem; }
#previewContent {
flex: 1;
overflow: auto;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
font-size: 1.1rem;
}
.docx-wrapper { padding: 1rem; }
.xlsx-table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
.xlsx-table th, .xlsx-table td {
border: 1px solid #ddd;
padding: 0.35rem 0.5rem;
text-align: left;
white-space: nowrap;
}
.xlsx-table th { background: #f0f0f0; font-weight: 600; position: sticky; top: 0; }
.xlsx-table tr:nth-child(even) { background: #fafafa; }
.xlsx-table tr:hover { background: #f0f7ff; }
.sheet-tabs { display: flex; gap: 0; border-bottom: 1px solid #ddd; background: #f5f5f5; }
.sheet-tab {
padding: 0.4rem 1rem;
cursor: pointer;
border: 1px solid transparent;
border-bottom: none;
font-size: 0.85rem;
background: transparent;
}
.sheet-tab:hover { background: #e8e8e8; }
.sheet-tab.active {
background: white;
border-color: #ddd;
border-bottom-color: white;
margin-bottom: -1px;
font-weight: 500;
}
</style>
</head>
<body>
<div class="toolbar">
<h1>${escapeHtml(fileName)}</h1>
<button class="btn" onclick="downloadFile()">Download</button>
</div>
${await getPreviewContent(file, currentBlobUrl)}
<script>
var blobUrl = "${currentBlobUrl}";
var fileName = "${escapeHtml(fileName).replace(/"/g, '\\"')}";
function downloadFile() {
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
<\/script>
</body>
</html>`;
// Reuse existing window if open, otherwise create new one
if (previewWindow && !previewWindow.closed) {
previewWindow.document.open();
previewWindow.document.write(previewHtml);
previewWindow.document.close();
previewWindow.focus();
} else {
// Calculate window size
const width = Math.round(screen.width * 0.6);
const height = Math.round(screen.height * 0.8);
const left = Math.round((screen.width - width) / 2);
const top = Math.round((screen.height - height) / 2);
previewWindow = window.open('', 'classifierPreview',
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`);
if (!previewWindow) {
// Popup blocked - fall back to new tab
window.open(currentBlobUrl, '_blank');
return;
}
// Poll for window close — beforeunload is unreliable for popup close buttons
const closePoll = setInterval(() => {
if (previewWindow && previewWindow.closed) {
clearInterval(closePoll);
previewWindow = null;
const btn = document.getElementById('togglePreviewBtn');
if (btn) btn.classList.remove('preview-active');
}
}, 500);
previewWindow.document.write(previewHtml);
previewWindow.document.close();
previewWindow.focus();
}
// For types that need decoding, render content after window is ready
const ext = (file.extension || '').toLowerCase();
if (ext === 'docx') {
await renderDocxInWindow(file);
} else if (ext === 'xlsx' || ext === 'xls') {
await renderXlsxInWindow(file);
} else if (TIFF_EXTENSIONS.includes(ext)) {
await renderTiffInWindow(file);
} else if (ZIP_EXTENSIONS.includes(ext)) {
await renderZipInWindow(file);
}
} catch (err) {
console.error('Error opening preview:', err);
alert(`Error opening preview: ${err.message}`);
}
}
/**
* Get preview content HTML based on file type
*/
async function getPreviewContent(file, blobUrl) {
const ext = file.extension.toLowerCase();
const previewType = getPreviewType(ext);
switch (previewType) {
case 'pdf':
return `<iframe src="${blobUrl}#view=FitV"></iframe>`;
case 'html':
// Render the HTML natively (not as literal text). Sandbox
// flags allow same-origin resource loads + opening links
// in real new tabs (target=_blank / middle-click), but
// NOT allow-scripts — archived HTML cannot run JS.
return `<iframe src="${blobUrl}" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>`;
case 'image':
return `<img src="${blobUrl}" alt="${escapeHtml(file.originalFilename)}" />`;
case 'text':
const text = await getFileText(file);
const maxLength = 100000;
const displayText = text.length > maxLength
? text.substring(0, maxLength) + '\n\n... (truncated)'
: text;
return `<pre>${escapeHtml(displayText)}</pre>`;
case 'docx':
case 'xlsx':
case 'tiff':
case 'zip':
return `<div id="previewContent"><div class="loading">Loading preview...</div></div>`;
default:
return `
<div class="unsupported">
<div class="icon">📄</div>
<p>Preview not available for ${ext} files</p>
<p style="margin-top: 0.5rem;">Click Download to view in external application</p>
</div>
`;
}
}
/**
* Get preview type from extension
*/
function getPreviewType(ext) {
// HTML is technically in TEXT_EXTENSIONS (used for editor
// syntax-highlighting elsewhere) but for previews we want to
// RENDER it, not show source. Check before the text branch.
if (ext === 'html' || ext === 'htm') return 'html';
if (PDF_EXTENSIONS.includes(ext)) return 'pdf';
if (TIFF_EXTENSIONS.includes(ext)) return 'tiff';
if (IMAGE_EXTENSIONS.includes(ext)) return 'image';
if (TEXT_EXTENSIONS.includes(ext)) return 'text';
if (ext === 'docx') return 'docx';
if (ext === 'xlsx' || ext === 'xls') return 'xlsx';
if (ZIP_EXTENSIONS.includes(ext)) return 'zip';
return 'none';
}
function getMimeType(ext) {
return window.app.modules.utils.getMimeType(ext);
}
/**
* Get file content as blob (handles both real and virtual files)
*/
async function getFileBlob(file) {
if (file.isVirtual) {
// Get from ZIP cache
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
if (!cached) throw new Error('ZIP not found in cache');
const zipEntry = cached.zip.file(file.zipEntryPath);
if (!zipEntry) throw new Error('File not found in ZIP');
// Get as arraybuffer and create blob with correct MIME type
const arrayBuffer = await zipEntry.async('arraybuffer');
const mimeType = getMimeType(file.extension);
return new Blob([arrayBuffer], { type: mimeType });
} else {
// Get from file handle
if (!file.handle) {
throw new Error('File handle not available');
}
return await file.handle.getFile();
}
}
/**
* Get file content as text (handles both real and virtual files)
*/
async function getFileText(file) {
if (file.isVirtual) {
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
if (!cached) throw new Error('ZIP not found in cache');
const zipEntry = cached.zip.file(file.zipEntryPath);
if (!zipEntry) throw new Error('File not found in ZIP');
return await zipEntry.async('string');
} else {
if (!file.handle) {
throw new Error('File handle not available');
}
const fileObj = await file.handle.getFile();
return await fileObj.text();
}
}
/**
* Render a DOCX file in the preview window using docx-preview library
*/
async function renderDocxInWindow(file) {
const 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');
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
container.innerHTML = '';
await window.docx.renderAsync(arrayBuffer, container);
} catch (err) {
console.error('Error rendering DOCX:', err);
container.innerHTML = `<div class="loading">Error rendering DOCX: ${err.message}<br>Click Download to view in Word.</div>`;
}
}
/**
* Render an XLSX/XLS file in the preview window using SheetJS
*/
async function renderXlsxInWindow(file) {
const 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');
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
container.innerHTML = '';
if (workbook.SheetNames.length > 1) {
const tabs = previewWindow.document.createElement('div');
tabs.className = 'sheet-tabs';
workbook.SheetNames.forEach((name, i) => {
const tab = previewWindow.document.createElement('button');
tab.className = 'sheet-tab' + (i === 0 ? ' active' : '');
tab.textContent = name;
tab.onclick = () => {
tabs.querySelectorAll('.sheet-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
renderSheetInWindow(workbook, name, tableContainer);
};
tabs.appendChild(tab);
});
container.appendChild(tabs);
}
const tableContainer = previewWindow.document.createElement('div');
tableContainer.style.flex = '1';
tableContainer.style.overflow = 'auto';
container.appendChild(tableContainer);
renderSheetInWindow(workbook, workbook.SheetNames[0], tableContainer);
} catch (err) {
console.error('Error rendering XLSX:', err);
container.innerHTML = `<div class="loading">Error rendering spreadsheet: ${err.message}<br>Click Download to view in Excel.</div>`;
}
}
/**
* Render a single sheet as an HTML table in the preview window
*/
function renderSheetInWindow(workbook, sheetName, container) {
const sheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(sheet, { editable: false });
container.innerHTML = html;
const table = container.querySelector('table');
if (table) table.className = 'xlsx-table';
}
/**
* Render a TIFF file in the preview window using shared zddc.preview.renderTiff
*/
async function renderTiffInWindow(file) {
const container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
await zddc.preview.renderTiff(previewWindow.document, container, arrayBuffer, {
fileName: zddc.joinExtension(file.originalFilename, file.extension)
});
} catch (err) {
console.error('Error rendering TIFF:', err);
container.innerHTML = `<div class="loading">Error rendering TIFF: ${err.message}<br>Click Download to view in another application.</div>`;
}
}
/**
* Render a ZIP listing in the preview window using shared zddc.preview.renderZipListing
*/
async function renderZipInWindow(file) {
const container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
await zddc.preview.renderZipListing(previewWindow.document, container, arrayBuffer, {
fileName: zddc.joinExtension(file.originalFilename, file.extension)
});
} catch (err) {
console.error('Error rendering ZIP listing:', err);
container.innerHTML = `<div class="loading">Error reading ZIP: ${err.message}</div>`;
}
}
/**
* Escape HTML for safe display
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Download current file
*/
async function downloadFile() {
if (!currentFile) return;
try {
const blob = await getFileBlob(currentFile);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = zddc.joinExtension(currentFile.originalFilename, currentFile.extension);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (err) {
console.error('Error downloading file:', err);
alert('Error downloading file: ' + err.message);
}
}
// Export module
window.app.modules.preview = {
init
};
})();
/**
* Column Resize Module
* Handles resizable table columns
*/
(function() {
'use strict';
let resizingColumn = null;
let startX = 0;
let startWidth = 0;
/**
* Initialize column resizing
*/
function init() {
const table = window.app.dom.spreadsheet;
const headers = table.querySelectorAll('thead th');
headers.forEach(th => {
// Skip if resize handle already exists
if (th.querySelector('.column-resizer')) return;
// Add resize handle
const resizer = document.createElement('div');
resizer.className = 'column-resizer';
th.appendChild(resizer);
// Mouse down on resizer
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
resizingColumn = th;
startX = e.pageX;
startWidth = th.offsetWidth;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
});
}
/**
* Handle mouse move during resize
*/
function handleMouseMove(e) {
if (!resizingColumn) return;
const diff = e.pageX - startX;
const newWidth = Math.max(50, startWidth + diff);
resizingColumn.style.width = newWidth + 'px';
resizingColumn.style.minWidth = newWidth + 'px';
resizingColumn.style.maxWidth = newWidth + 'px';
}
/**
* Handle mouse up - end resize
*/
function handleMouseUp() {
resizingColumn = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
// Export module
window.app.modules.resize = {
init
};
})();
/**
* Filter Module
* Column filter UI: initialises static inputs in thead, wires events.
*/
(function() {
'use strict';
/**
* Initialize filtering — wire delegated events on thead.
* Filter inputs already exist in the static template; no dynamic injection needed.
*/
function init() {
const thead = window.app.dom.spreadsheet
? window.app.dom.spreadsheet.querySelector('thead')
: document.querySelector('#spreadsheet thead');
if (!thead) return;
thead.addEventListener('input', (e) => {
if (e.target.matches('.column-filter[data-filter-field]')) {
e.stopPropagation();
const field = e.target.getAttribute('data-filter-field');
const raw = e.target.value.trim();
const ast = window.zddc.filter.parse(raw);
window.app.modules.store.setFilter(field, raw, ast);
}
});
thead.addEventListener('keydown', (e) => {
if (!e.target.matches('.column-filter[data-filter-field]')) return;
if (e.key === 'Escape') {
e.target.value = '';
const field = e.target.getAttribute('data-filter-field');
window.app.modules.store.setFilter(field, '', null);
e.preventDefault();
} else if (e.key === 'Enter') {
e.preventDefault();
const inputs = Array.from(thead.querySelectorAll('.column-filter'));
const idx = inputs.indexOf(e.target);
if (idx !== -1) inputs[(idx + 1) % inputs.length].focus();
}
});
thead.addEventListener('click', (e) => {
if (e.target.matches('.column-filter')) {
e.stopPropagation();
}
});
}
/**
* Clear all filters — reset inputs and store.
*/
function clearFilters() {
document.querySelectorAll('.column-filter').forEach(input => {
input.value = '';
});
window.app.modules.store.setAllFilters({});
}
window.app.modules.filter = {
init,
clearFilters
};
})();
/**
* Sort Module
* Handles multi-column sorting for the spreadsheet
*/
(function() {
'use strict';
// Sort state: array of {column, direction}
let sortState = [];
/**
* Initialize sorting
*/
function init() {
const table = window.app.dom.spreadsheet;
const headers = table.querySelectorAll('thead th');
headers.forEach((th, index) => {
// Skip row number column
if (th.classList.contains('col-row-num')) return;
// Skip if already initialized
if (th.querySelector('.sort-indicator')) return;
// Make header clickable
th.style.cursor = 'pointer';
th.style.userSelect = 'none';
// Add sort indicator container
const sortIndicator = document.createElement('span');
sortIndicator.className = 'sort-indicator';
// Insert after the text node (before any br or filter)
const firstChild = th.firstChild;
if (firstChild && firstChild.nodeType === Node.TEXT_NODE) {
// Insert after text node
firstChild.after(sortIndicator);
} else {
// Prepend to header
th.insertBefore(sortIndicator, firstChild);
}
// Click to sort (only add once)
const handleClick = (e) => {
// Don't sort if clicking on resizer or filter input
if (e.target.classList.contains('column-resizer')) return;
if (e.target.classList.contains('column-filter')) return;
const columnName = th.className.replace('col-', '');
handleSort(columnName, e.ctrlKey || e.metaKey);
};
th.addEventListener('click', handleClick);
});
}
/**
* Apply default sort (called after initial render)
*/
function applyDefaultSort() {
const files = window.app.modules.store.getDisplayFiles();
if (files.length > 0) {
sortState = [{ column: 'original', direction: 'asc' }];
applySorts();
updateSortIndicators();
}
}
/**
* Handle sort click
*/
function handleSort(columnName, multiSort) {
// Use store to toggle sort
window.app.modules.store.toggleSort(columnName, multiSort);
}
/**
* Apply sort to files array (pure function - doesn't mutate)
*/
function applySortToFiles(files) {
if (sortState.length === 0) {
return files;
}
return [...files].sort((a, b) => {
for (const sort of sortState) {
const result = compareValues(a, b, sort.column, sort.direction);
if (result !== 0) return result;
}
return 0;
});
}
/**
* Apply all sorts (legacy - triggers render)
*/
function applySorts() {
window.app.modules.spreadsheet.render();
}
/**
* Apply default sort
*/
function applyDefaultSort() {
const files = window.app.modules.store.getDisplayFiles();
if (files.length > 0 && sortState.length === 0) {
sortState = [{ column: 'original', direction: 'asc' }];
window.app.modules.spreadsheet.render();
}
}
/**
* Get current sort state
*/
function getSortState() {
return sortState;
}
/**
* Update sort indicators in headers
*/
function updateIndicators() {
const table = window.app.dom.spreadsheet;
const headers = table.querySelectorAll('thead th');
headers.forEach(th => {
const indicator = th.querySelector('.sort-indicator');
if (!indicator) return;
const columnName = th.className.replace('col-', '');
const sortIndex = sortState.findIndex(s => s.column === columnName);
if (sortIndex >= 0) {
const sort = sortState[sortIndex];
const arrow = sort.direction === 'asc' ? '▲' : '▼';
const priority = sortState.length > 1 ? (sortIndex + 1) : '';
indicator.textContent = ` ${arrow}${priority}`;
indicator.style.display = 'inline';
} else {
indicator.textContent = '';
indicator.style.display = 'none';
}
});
}
/**
* Compare two values for sorting
*/
function compareValues(a, b, columnName, direction) {
let aVal, bVal;
// Get values based on column
switch (columnName) {
case 'original':
aVal = a.originalFilename || '';
bVal = b.originalFilename || '';
break;
case 'extension':
aVal = a.extension || '';
bVal = b.extension || '';
break;
case 'new':
case 'newFilename':
aVal = a.manualFilename || window.app.modules.spreadsheet.computeNewFilename(a, 0);
bVal = b.manualFilename || window.app.modules.spreadsheet.computeNewFilename(b, 0);
break;
case 'trackingNumber':
aVal = a.trackingNumber || '';
bVal = b.trackingNumber || '';
break;
case 'revision':
aVal = a.revision || '';
bVal = b.revision || '';
break;
case 'status':
aVal = a.status || '';
bVal = b.status || '';
break;
case 'title':
aVal = a.title || '';
bVal = b.title || '';
break;
case 'sha256':
aVal = a.sha256 || '';
bVal = b.sha256 || '';
break;
default:
return 0;
}
// Natural sort for strings (handles numbers within strings)
const comparison = aVal.localeCompare(bVal, undefined, { numeric: true, sensitivity: 'base' });
return direction === 'asc' ? comparison : -comparison;
}
/**
* Clear all sorts
*/
function clearSorts() {
sortState = [];
applySorts();
}
// Export module
window.app.modules.sort = {
init,
applyDefaultSort,
applySorts,
applySortToFiles,
updateIndicators,
getSortState,
clearSorts
};
})();
/**
* Excel Integration Module
* Toast notifications and hash export
*/
(function() {
'use strict';
/**
* Show toast notification
*/
function showToast(message, type = 'info') {
// Remove existing toast
const existing = document.querySelector('.toast');
if (existing) {
existing.remove();
}
// Create toast
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
toast.classList.add('toast-fade');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
/**
* Export SHA256 hashes in sha256sum format
*/
async function exportHashes() {
const files = window.app.modules.store.getDisplayFiles();
if (files.length === 0) {
alert('No files to export');
return;
}
// Check if SHA256 is enabled
if (!window.app.calculateSha256) {
alert('Please enable SHA256 checkbox first and wait for hashes to calculate');
return;
}
try {
// Build sha256sum format: hash *filepath
const lines = [];
// Get root path
const rootPath = await getFullPath(window.app.rootHandle);
for (const file of files) {
if (!file.sha256 || file.sha256 === 'calculating...' || file.sha256 === 'error') {
continue; // Skip files without calculated hash
}
// Get full path from root
const folderPath = await getFullPath(file.folderHandle);
const fullPath = `${folderPath}/${zddc.joinExtension(file.originalFilename, file.extension)}`;
// Format: hash *filepath (asterisk indicates binary mode)
lines.push(`${file.sha256} *${fullPath}`);
}
if (lines.length === 0) {
alert('No SHA256 hashes available. Enable SHA256 and wait for calculation to complete.');
return;
}
// Create output
const output = lines.join('\n');
// Generate filename with timestamp
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, -5); // YYYY-MM-DDTHH-MM-SS
const filename = `sha256sums_${timestamp}.txt`;
// Download as file
const blob = new Blob([output], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Show success message
showToast(`✓ Downloaded ${lines.length} hash(es) to ${filename}`, 'success');
} catch (err) {
console.error('Error exporting hashes:', err);
alert('Error exporting hashes: ' + err.message);
}
}
/**
* Get full path from directory handle (all the way to root)
*/
async function getFullPath(dirHandle) {
const parts = [];
let current = dirHandle;
// Walk up to root - collect ALL parent folders
while (current) {
parts.unshift(current.name);
try {
// Try to get parent
if (typeof current.getParent === 'function') {
const parent = await current.getParent();
if (parent && parent !== current) {
current = parent;
continue;
}
}
break;
} catch {
break;
}
}
return parts.join('/');
}
// Export module
window.app.modules.excel = {
showToast,
exportHashes
};
})();
/**
* 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();
}
}());
</script>
</body>
</html>