ZDDC/website/releases/classifier_alpha.html
ZDDC c95f07966d feat(tools,build): in-flight HTML-tool reworks and build-infra updates
Bundles a stretch of in-progress work across the SPA tools so the
tree returns to a coherent shippable state ahead of cutting a new
zddc-server stable image:

- landing: substantial rework of the project picker (sortable/filterable
  table, presets refactor, ?projects= filter, ?v= channel propagation,
  loading/error states)
- archive: presets cleanup, source.js refactor, filtering/url-state
  alignment with the landing page
- mdedit: file-system module split, resizer, file-tree improvements,
  base/toc styling tweaks
- transmittal/classifier: small template touch-ups for shared chrome
- shared: build-lib.sh helpers, new favicon.svg
- bootstrap, build.sh: pick up the channel-aware install/track zip
  generation
- tests: new landing.spec.js, expanded archive/mdedit/build-label specs
- docs: CLAUDE.md picks up the zddc-server section and freshens the
  alpha-build exception note
- regenerated artifacts: install.zip, track-{alpha,beta,stable}.zip,
  *_alpha.html — these are produced by `sh build.sh` and per project
  convention are committed alongside the source changes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:52:27 -05:00

6972 lines
219 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC 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;
}
/* ── 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">
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-29 17:45:13 · cf4101b-dirty</span></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 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;
// File type mappings (extensions stored without leading dot, matching shared/zddc.js)
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'];
const TEXT_EXTENSIONS = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'html', 'css', 'js', 'ts', 'py', 'sh', 'bat', 'yaml', 'yml', 'ini', 'cfg', 'conf'];
const PDF_EXTENSIONS = ['pdf'];
const ZIP_EXTENSIONS = ['zip'];
// Cache for lazily loaded CDN libraries
const loadedLibraries = new Map();
/**
* Lazily load a script from CDN. Returns a promise that resolves when loaded.
* Caches the promise so subsequent calls return immediately.
*/
function loadLibrary(url) {
if (loadedLibraries.has(url)) return loadedLibraries.get(url);
const promise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = () => reject(new Error(`Failed to load library: ${url}`));
document.head.appendChild(script);
});
loadedLibraries.set(url, promise);
return promise;
}
/**
* 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 office types, 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);
}
} 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 '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':
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) {
if (PDF_EXTENSIONS.includes(ext)) return 'pdf';
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';
}
/**
* 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>