Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 4s
Bake the latest dev cut of all six tools into zddc/internal/apps/embedded/ so the dev image (built from main) ships the new browse filter UI + vendored JSZip. Triggers notify-chart-dev which bumps the chart's develop branch with appVersion=v0.0.16-beta-<sha>.
7572 lines
245 KiB
HTML
7572 lines
245 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;
|
||
}
|
||
|
||
/* Brand logo — sits left of the title in every tool's app-header.
|
||
Self-contained: the SVG provides its own dark blue rounded background,
|
||
so no extra wrapper styling is needed. */
|
||
.app-header__logo {
|
||
width: 26px;
|
||
height: 26px;
|
||
flex-shrink: 0;
|
||
display: block;
|
||
}
|
||
|
||
/* ── Build timestamp ──────────────────────────────────────────────────────── */
|
||
.build-timestamp {
|
||
font-size: 0.55rem;
|
||
color: var(--text-muted);
|
||
opacity: 0.7;
|
||
font-weight: 300;
|
||
white-space: nowrap;
|
||
padding-top: 0.15rem;
|
||
}
|
||
|
||
/* Title + timestamp stacked vertically on the left side of the header */
|
||
.header-title-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
line-height: 1;
|
||
}
|
||
|
||
/* ── Icon buttons (help, theme, refresh) ─────────────────────────────────── */
|
||
/* Square, centered — overrides the asymmetric text-button padding/line-height */
|
||
#help-btn,
|
||
#theme-btn,
|
||
#refreshHeaderBtn {
|
||
width: 2rem;
|
||
height: 2rem;
|
||
padding: 0;
|
||
line-height: 1;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
|
||
|
||
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
|
||
#theme-btn,
|
||
#help-btn {
|
||
font-size: 1rem;
|
||
}
|
||
|
||
/* ── Help panel (shared slide-out drawer) ─────────────────────────────────── */
|
||
/* Used by all four tools. Toggle open/close via shared/help.js. */
|
||
|
||
.help-panel {
|
||
position: fixed;
|
||
top: 0;
|
||
right: 0;
|
||
width: min(420px, 85vw);
|
||
height: 100vh;
|
||
z-index: 1000;
|
||
background: var(--bg);
|
||
border-left: 1px solid var(--border);
|
||
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.08);
|
||
display: flex;
|
||
flex-direction: column;
|
||
transform: translateX(100%);
|
||
transition: transform 0.25s ease;
|
||
}
|
||
|
||
.help-panel:not([hidden]) {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.help-panel[hidden] {
|
||
display: flex;
|
||
transform: translateX(100%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.help-panel__header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.75rem 1rem;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.help-panel__title {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
margin: 0;
|
||
}
|
||
|
||
.help-panel__close {
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-muted);
|
||
font-size: 1.35rem;
|
||
cursor: pointer;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: var(--radius);
|
||
line-height: 1;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
|
||
.help-panel__close:hover {
|
||
color: var(--text);
|
||
background: var(--bg-secondary);
|
||
}
|
||
|
||
.help-panel__body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 1rem 1rem 2rem;
|
||
font-size: 0.85rem;
|
||
line-height: 1.6;
|
||
color: var(--text);
|
||
}
|
||
|
||
.help-panel__body h3 {
|
||
font-size: 0.95rem;
|
||
font-weight: 700;
|
||
margin: 1.25rem 0 0.35rem;
|
||
color: var(--text);
|
||
border-bottom: 1px solid var(--border);
|
||
padding-bottom: 0.15rem;
|
||
}
|
||
|
||
.help-panel__body h3:first-child {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.help-panel__body h4 {
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
margin: 1.25rem 0 0.3rem;
|
||
padding-left: 0.5rem;
|
||
border-left: 3px solid var(--border-dark);
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.help-panel__body p {
|
||
margin: 0 0 0.5rem;
|
||
}
|
||
|
||
.help-panel__body ol,
|
||
.help-panel__body ul {
|
||
padding-left: 1.5rem;
|
||
margin: 0.3rem 0 0.5rem;
|
||
}
|
||
|
||
.help-panel__body li {
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
|
||
.help-panel__body dl {
|
||
margin: 0.3rem 0;
|
||
}
|
||
|
||
.help-panel__body dt {
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
}
|
||
|
||
.help-panel__body dd {
|
||
margin: 0 0 0.5rem 1rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.help-panel__body code {
|
||
font-family: var(--font-mono);
|
||
font-size: 0.8em;
|
||
background: var(--bg-secondary);
|
||
padding: 0.1em 0.3em;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.help-badge {
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
padding: 0.1rem 0.35rem;
|
||
border-radius: var(--radius);
|
||
vertical-align: middle;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.help-badge--draft {
|
||
color: #2563eb;
|
||
background: #eff6ff;
|
||
}
|
||
|
||
.help-badge--published {
|
||
color: #7c3aed;
|
||
background: #f5f3ff;
|
||
}
|
||
|
||
/* Shrink main content when help panel is open */
|
||
body.help-open .app-header {
|
||
margin-right: min(420px, 85vw);
|
||
}
|
||
|
||
/* ── Column filter inputs (shared across archive, classifier, transmittal) ─── */
|
||
.column-filter {
|
||
display: block;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
margin-top: 0.25rem;
|
||
padding: 0.2rem 0.4rem;
|
||
font-size: 0.8rem;
|
||
font-family: var(--font);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
transition: border-color 0.15s;
|
||
}
|
||
|
||
.column-filter:focus {
|
||
border-color: var(--primary);
|
||
outline: none;
|
||
box-shadow: 0 0 0 1px rgba(42, 90, 138, 0.35);
|
||
}
|
||
|
||
.column-filter::placeholder {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* Classifier-specific base overrides
|
||
Reset, tokens, buttons, and font are provided by shared/base.css */
|
||
|
||
#app {
|
||
width: 100vw;
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* Utility */
|
||
.text-muted { color: var(--text-muted); }
|
||
.text-success { color: var(--success); }
|
||
.text-warning { color: var(--warning); }
|
||
.text-danger { color: var(--danger); }
|
||
|
||
/* Checkbox label */
|
||
.checkbox-label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.checkbox-label input[type="checkbox"] {
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* ── Toast notifications (classifier-only) ───────────────────────────────── */
|
||
/* shared/base.css intentionally omits toast CSS; only classifier uses toasts. */
|
||
.toast {
|
||
position: fixed;
|
||
bottom: 2rem;
|
||
right: 2rem;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
padding: 0.875rem 1.25rem;
|
||
border-radius: var(--radius);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
z-index: 9000;
|
||
max-width: 400px;
|
||
font-size: 0.875rem;
|
||
animation: zddc-toast-in 0.3s ease-out;
|
||
}
|
||
|
||
.toast-success { border-left: 4px solid var(--success); }
|
||
.toast-error { border-left: 4px solid var(--danger); }
|
||
.toast-info { border-left: 4px solid var(--info); }
|
||
.toast-warning { border-left: 4px solid var(--warning); }
|
||
|
||
.toast-fade {
|
||
animation: zddc-toast-out 0.3s ease-out forwards;
|
||
}
|
||
|
||
@keyframes zddc-toast-in {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
|
||
@keyframes zddc-toast-out {
|
||
from { transform: translateX(0); opacity: 1; }
|
||
to { transform: translateX(100%); opacity: 0; }
|
||
}
|
||
|
||
/* Classifier layout — tokens from shared/base.css */
|
||
|
||
/* Empty State — positioned below the app header */
|
||
.empty-state {
|
||
position: absolute;
|
||
top: 50px; /* clear the header */
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--bg);
|
||
z-index: 10;
|
||
}
|
||
|
||
.empty-state-content {
|
||
text-align: center;
|
||
max-width: 500px;
|
||
padding: 2rem;
|
||
}
|
||
|
||
.empty-state-content h2 {
|
||
color: var(--text);
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.empty-state-content p {
|
||
margin-bottom: 1rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.empty-state-content .note {
|
||
font-size: 0.85rem;
|
||
font-style: italic;
|
||
}
|
||
|
||
.welcome-list {
|
||
text-align: left;
|
||
margin: 0.5rem auto;
|
||
max-width: 400px;
|
||
}
|
||
|
||
.empty-state.drag-over {
|
||
background: var(--primary-light);
|
||
outline: 2px dashed var(--primary);
|
||
outline-offset: -4px;
|
||
}
|
||
|
||
/* Browser Warning */
|
||
.browser-warning {
|
||
background-color: rgba(217, 119, 6, 0.08);
|
||
border: 2px solid var(--warning);
|
||
border-radius: var(--radius);
|
||
padding: 1.5rem;
|
||
margin: 1.5rem 0;
|
||
text-align: left;
|
||
}
|
||
|
||
.browser-warning h3 {
|
||
color: var(--warning);
|
||
margin-top: 0;
|
||
}
|
||
|
||
.browser-warning ul {
|
||
margin: 0.5rem 0;
|
||
padding-left: 1.5rem;
|
||
}
|
||
|
||
/* Main App */
|
||
.main-app {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
background-color: var(--bg);
|
||
position: relative;
|
||
}
|
||
|
||
/* Header — shared/base.css provides .app-header base */
|
||
.app-header {
|
||
padding: 0.5rem 1rem;
|
||
}
|
||
|
||
.header-left,
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.header-divider {
|
||
color: var(--border);
|
||
margin: 0 0.25rem;
|
||
}
|
||
|
||
/* Main Content */
|
||
.main-content {
|
||
display: flex;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Folder Tree Pane */
|
||
.folder-tree-pane {
|
||
width: 300px;
|
||
min-width: 150px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background-color: var(--bg-secondary);
|
||
border-right: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
transition: width 0.2s ease, min-width 0.2s ease;
|
||
}
|
||
|
||
.folder-tree-pane.collapsed {
|
||
width: 40px !important;
|
||
min-width: 40px !important;
|
||
max-width: 40px !important;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.folder-tree-pane.collapsed .pane-header-controls,
|
||
.folder-tree-pane.collapsed .folder-tree,
|
||
.folder-tree-pane.collapsed .pane-header h3 {
|
||
display: none;
|
||
}
|
||
|
||
.folder-tree-pane.collapsed .pane-header {
|
||
padding: 0.5rem;
|
||
justify-content: center;
|
||
}
|
||
|
||
.folder-tree-pane.collapsed .pane-header-title {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.pane-header-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.collapse-tree-btn {
|
||
padding: 0.25rem 0.5rem;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
/* Resize Handle */
|
||
.resize-handle {
|
||
position: absolute;
|
||
right: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 5px;
|
||
cursor: col-resize;
|
||
background-color: transparent;
|
||
z-index: 10;
|
||
}
|
||
|
||
.resize-handle:hover {
|
||
background-color: var(--primary);
|
||
}
|
||
|
||
.pane-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.75rem 1rem;
|
||
background-color: var(--bg);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.pane-header-left,
|
||
.pane-header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.pane-header h3 {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
margin: 0;
|
||
}
|
||
|
||
.pane-header-controls {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.folder-stats,
|
||
.file-stats {
|
||
display: flex;
|
||
gap: 1rem;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.folder-tree {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 0.5rem;
|
||
}
|
||
|
||
/* Folder Item */
|
||
.folder-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0.5rem;
|
||
cursor: pointer;
|
||
border-radius: var(--radius);
|
||
user-select: none;
|
||
transition: background-color 0.15s;
|
||
}
|
||
|
||
.folder-item:hover {
|
||
background-color: var(--bg-hover);
|
||
}
|
||
|
||
.folder-item.selected {
|
||
background-color: var(--bg-selected);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.folder-item.folder-hover-highlight {
|
||
background-color: rgba(217, 119, 6, 0.12);
|
||
border-left: 3px solid var(--warning);
|
||
transition: background-color 0.2s, border-left 0.2s;
|
||
}
|
||
|
||
.folder-item.has-unsaved {
|
||
border-left: 3px solid var(--warning);
|
||
}
|
||
|
||
.folder-toggle {
|
||
width: 20px;
|
||
height: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.folder-icon {
|
||
margin-right: 0.5rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.folder-name {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.folder-count {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
margin-left: 0.5rem;
|
||
}
|
||
|
||
.folder-children {
|
||
margin-left: 1.5rem;
|
||
}
|
||
|
||
/* Spreadsheet Pane */
|
||
.spreadsheet-pane {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.spreadsheet-container {
|
||
flex: 1;
|
||
overflow: auto;
|
||
background-color: var(--bg);
|
||
}
|
||
|
||
/* ZIP Extract Button in Tree */
|
||
.zip-extract-btn {
|
||
margin-left: auto;
|
||
padding: 0.15rem 0.4rem;
|
||
font-size: 0.7rem;
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.folder-item:hover .zip-extract-btn {
|
||
opacity: 1;
|
||
}
|
||
|
||
.zip-extract-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: wait;
|
||
}
|
||
|
||
/* ZIP Extract All Button */
|
||
.zip-extract-all-btn {
|
||
margin-left: auto;
|
||
padding: 0.15rem 0.4rem;
|
||
font-size: 0.7rem;
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.folder-item:hover .zip-extract-all-btn {
|
||
opacity: 1;
|
||
}
|
||
|
||
.zip-extract-all-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: wait;
|
||
}
|
||
|
||
/**
|
||
* Spreadsheet Styles
|
||
* Table, cells, editing, and row states
|
||
*/
|
||
|
||
.spreadsheet {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.875rem;
|
||
background-color: var(--bg);
|
||
}
|
||
|
||
/* Selected cells */
|
||
.selected-cell {
|
||
background-color: rgba(0, 123, 255, 0.2) !important;
|
||
outline: 1px solid var(--primary);
|
||
}
|
||
|
||
/* Auto-populated cells (gray text to indicate matches filename) */
|
||
.cell-editable.auto-populated {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* Changed fields (blue text to indicate value differs from original filename) */
|
||
.cell-editable.field-changed {
|
||
color: var(--primary);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.spreadsheet thead {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
background-color: var(--bg-secondary);
|
||
}
|
||
|
||
.spreadsheet th {
|
||
padding: 0.75rem 0.5rem;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
border-bottom: 2px solid var(--border);
|
||
background-color: var(--bg-secondary);
|
||
position: relative;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.spreadsheet th:hover:not(.col-row-num) {
|
||
background-color: var(--border);
|
||
}
|
||
|
||
/* Sort indicator */
|
||
.sort-indicator {
|
||
display: inline-block;
|
||
font-size: 0.75rem;
|
||
color: var(--primary);
|
||
margin-left: 0.25rem;
|
||
margin-right: 0.25rem;
|
||
font-weight: bold;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.spreadsheet td {
|
||
padding: 0.5rem;
|
||
border-bottom: 1px solid var(--border);
|
||
vertical-align: top;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Column resizer */
|
||
.column-resizer {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
width: 5px;
|
||
height: 100%;
|
||
cursor: col-resize;
|
||
user-select: none;
|
||
z-index: 1;
|
||
}
|
||
|
||
.column-resizer:hover {
|
||
background-color: var(--primary);
|
||
}
|
||
|
||
/* Column Widths */
|
||
.col-row-num {
|
||
width: 50px;
|
||
text-align: center;
|
||
background-color: var(--bg-secondary);
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
|
||
.col-original {
|
||
min-width: 250px;
|
||
}
|
||
|
||
.col-extension {
|
||
width: 60px;
|
||
text-align: center;
|
||
}
|
||
|
||
.col-new {
|
||
min-width: 250px;
|
||
}
|
||
|
||
.col-trackingNumber {
|
||
min-width: 200px;
|
||
width: 200px;
|
||
}
|
||
|
||
.col-revision {
|
||
width: 80px;
|
||
}
|
||
|
||
.col-status {
|
||
width: 100px;
|
||
}
|
||
|
||
.col-title {
|
||
min-width: 200px;
|
||
}
|
||
|
||
.col-sha256 {
|
||
min-width: 150px;
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.col-actions {
|
||
width: 100px;
|
||
text-align: center;
|
||
}
|
||
|
||
/* Row States */
|
||
.spreadsheet tbody tr {
|
||
transition: background-color 0.15s;
|
||
}
|
||
|
||
.spreadsheet tbody tr:hover {
|
||
background-color: var(--bg-hover);
|
||
}
|
||
|
||
.spreadsheet tbody tr.modified {
|
||
border-left: 3px solid var(--warning);
|
||
}
|
||
|
||
.spreadsheet tbody tr.error {
|
||
border-left: 3px solid var(--danger);
|
||
background-color: rgba(220, 53, 69, 0.08);
|
||
}
|
||
|
||
.spreadsheet tbody tr.saving {
|
||
opacity: 0.6;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Cell Content */
|
||
.cell-content {
|
||
display: block;
|
||
width: 100%;
|
||
}
|
||
|
||
.cell-link {
|
||
color: var(--primary);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.cell-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.cell-extension {
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.cell-computed {
|
||
font-style: italic;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* Editable Cells */
|
||
.cell-editable {
|
||
cursor: text;
|
||
position: relative;
|
||
}
|
||
|
||
.cell-editable:hover {
|
||
background-color: var(--bg-hover);
|
||
}
|
||
|
||
.cell-content[contenteditable="true"] {
|
||
outline: 2px solid var(--primary);
|
||
outline-offset: 0;
|
||
background-color: var(--bg);
|
||
min-height: 1.5em;
|
||
}
|
||
|
||
.cell-content[contenteditable="true"]:focus {
|
||
outline: 2px solid var(--primary);
|
||
outline-offset: 0;
|
||
}
|
||
|
||
.cell-content.editing {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
/* Computed cells */
|
||
.cell-editable.computed {
|
||
font-style: italic;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.cell-editable.computed:hover {
|
||
font-style: normal;
|
||
color: var(--text);
|
||
}
|
||
|
||
/* Validation states */
|
||
.validation-error {
|
||
background-color: rgba(220, 53, 69, 0.1);
|
||
border-left: 3px solid var(--danger);
|
||
}
|
||
|
||
.validation-warning {
|
||
background-color: rgba(255, 193, 7, 0.1);
|
||
border-left: 3px solid #ffc107;
|
||
}
|
||
|
||
/* Inline Actions */
|
||
.inline-actions {
|
||
position: absolute;
|
||
right: 0.25rem;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
background-color: var(--bg);
|
||
padding: 0.125rem;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.cell-editable {
|
||
position: relative;
|
||
padding-right: 3.5rem; /* Space for buttons */
|
||
}
|
||
|
||
.btn-inline {
|
||
border: none;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 3px;
|
||
transition: all 0.15s;
|
||
font-weight: bold;
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.btn-save {
|
||
color: var(--success);
|
||
}
|
||
|
||
.btn-save:hover {
|
||
background-color: var(--success);
|
||
color: white;
|
||
}
|
||
|
||
.btn-cancel {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.btn-cancel:hover {
|
||
background-color: var(--danger);
|
||
color: white;
|
||
}
|
||
|
||
/* Formula Preview */
|
||
.formula-preview {
|
||
position: absolute;
|
||
bottom: 100%;
|
||
left: 0;
|
||
background-color: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
padding: 0.5rem;
|
||
font-size: 12px;
|
||
min-width: 200px;
|
||
z-index: 100;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.formula-preview-label {
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.formula-preview-value {
|
||
color: var(--text);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.formula-preview-value.valid {
|
||
color: var(--success);
|
||
}
|
||
|
||
.formula-preview-value.invalid {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.preview-check {
|
||
color: var(--success);
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
padding: 0.25rem;
|
||
border-radius: 3px;
|
||
transition: background-color 0.15s;
|
||
}
|
||
|
||
.preview-check:hover {
|
||
background-color: rgba(40, 167, 69, 0.1);
|
||
}
|
||
|
||
.preview-error {
|
||
color: var(--danger);
|
||
font-size: 16px;
|
||
}
|
||
|
||
.formula-preview-errors {
|
||
margin-top: 0.25rem;
|
||
font-size: 11px;
|
||
color: var(--danger);
|
||
white-space: normal;
|
||
}
|
||
|
||
/* Validation States */
|
||
.cell-warning {
|
||
background-color: rgba(255, 193, 7, 0.08);
|
||
border-left: 3px solid var(--warning);
|
||
}
|
||
|
||
.cell-error {
|
||
background-color: rgba(220, 53, 69, 0.08);
|
||
border-left: 3px solid var(--danger);
|
||
}
|
||
|
||
.validation-icon {
|
||
display: inline-block;
|
||
margin-left: 0.25rem;
|
||
cursor: help;
|
||
}
|
||
|
||
/* SHA256 Column */
|
||
.sha256-hash {
|
||
font-family: var(--font-mono);
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.sha256-calculating {
|
||
font-style: italic;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* Action Buttons */
|
||
.row-actions {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
justify-content: center;
|
||
}
|
||
|
||
.btn-icon {
|
||
width: 28px;
|
||
height: 28px;
|
||
padding: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
background-color: var(--bg);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.btn-icon:hover:not(:disabled) {
|
||
background-color: var(--bg-hover);
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.btn-icon:disabled {
|
||
opacity: 0.3;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-save {
|
||
color: var(--success);
|
||
}
|
||
|
||
.btn-cancel {
|
||
color: var(--danger);
|
||
}
|
||
|
||
/* Empty State */
|
||
.empty-state {
|
||
padding: 3rem;
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.empty-state h3 {
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
/* Spreadsheet Empty State */
|
||
.spreadsheet-empty {
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
/* Selection Highlight */
|
||
.cell-selected {
|
||
outline: 2px solid var(--primary);
|
||
outline-offset: -2px;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* Scrollbar Styling */
|
||
.spreadsheet-container::-webkit-scrollbar {
|
||
width: 12px;
|
||
height: 12px;
|
||
}
|
||
|
||
.spreadsheet-container::-webkit-scrollbar-track {
|
||
background-color: var(--bg-secondary);
|
||
}
|
||
|
||
.spreadsheet-container::-webkit-scrollbar-thumb {
|
||
background-color: var(--border-dark);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.spreadsheet-container::-webkit-scrollbar-thumb:hover {
|
||
background-color: var(--text-muted);
|
||
}
|
||
|
||
/* Preview button active state */
|
||
#togglePreviewBtn.preview-active {
|
||
background-color: var(--primary);
|
||
color: white;
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<!-- Main Application -->
|
||
<div id="mainApp" class="main-app">
|
||
<!-- Header -->
|
||
<header class="app-header">
|
||
<div class="header-left">
|
||
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
|
||
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
||
<g fill="#fff">
|
||
<rect x="14" y="18" width="36" height="7"/>
|
||
<polygon points="43,25 50,25 21,43 14,43"/>
|
||
<rect x="14" y="43" width="36" height="7"/>
|
||
</g>
|
||
</svg>
|
||
<div class="header-title-group">
|
||
<span class="app-header__title">ZDDC Classifier</span>
|
||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · 582db6d</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" aria-label="Refresh" style="font-size:1.1rem;">⟳</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">×</button>
|
||
</div>
|
||
<div class="help-panel__body">
|
||
<h3>What is the Classifier?</h3>
|
||
<p>The Classifier is a spreadsheet-based tool for renaming files to ZDDC naming conventions. It reads a folder of files and presents them in an editable grid where you can set tracking number, revision, status, and title — then saves the renamed files back to disk.</p>
|
||
|
||
<h3>Getting Started</h3>
|
||
<ol>
|
||
<li>Click <strong>Select Directory</strong> to open a folder containing files to rename.</li>
|
||
<li>The folder tree on the left shows all sub-folders. Click a folder to load its files.</li>
|
||
<li>Edit cells in the spreadsheet to set the new filename components.</li>
|
||
<li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li>
|
||
</ol>
|
||
|
||
<h3>Folder Tree</h3>
|
||
<dl>
|
||
<dt>Multi-select</dt>
|
||
<dd>Hold <kbd>Ctrl</kbd> and click to select multiple folders. Hold <kbd>Shift</kbd> to select a range. Files from all selected folders are shown together.</dd>
|
||
<dt>Hide Compliant</dt>
|
||
<dd>Hides folders where all files already have valid ZDDC names, letting you focus on work remaining.</dd>
|
||
<dt>Auto-scroll</dt>
|
||
<dd>When enabled, the folder tree scrolls to highlight the folder containing the row you are editing.</dd>
|
||
</dl>
|
||
|
||
<h3>Spreadsheet Editing</h3>
|
||
<dl>
|
||
<dt>Direct cell editing</dt>
|
||
<dd>Click any cell in the New Filename, Tracking, Rev, Status, or Title columns to edit it. Press <kbd>Enter</kbd> to confirm, <kbd>Escape</kbd> to cancel.</dd>
|
||
<dt>RC References</dt>
|
||
<dd>Type a formula like <code>=R[-1]C</code> to copy the value from the cell one row above in the same column — similar to Excel relative references.</dd>
|
||
<dt>Regex capture groups</dt>
|
||
<dd>Type a formula like <code>=RE(RC[-3], "(\w+)-(\d+)", "$1")</code> to extract a pattern from another cell using a regular expression.</dd>
|
||
<dt>Validation</dt>
|
||
<dd>Cells are validated automatically. Invalid values are highlighted in red. The New Filename column shows the composed result.</dd>
|
||
<dt>Column Filters</dt>
|
||
<dd>Each column header has a filter input. Supported syntax:</dd>
|
||
</dl>
|
||
<dl>
|
||
<dt><code>term</code></dt>
|
||
<dd>Contains "term" (case-insensitive)</dd>
|
||
<dt><code>!term</code></dt>
|
||
<dd>Does not contain "term"</dd>
|
||
<dt><code>^term</code></dt>
|
||
<dd>Starts with "term"</dd>
|
||
<dt><code>term$</code></dt>
|
||
<dd>Ends with "term"</dd>
|
||
<dt><code>a b</code></dt>
|
||
<dd>Matches both (AND)</dd>
|
||
<dt><code>a | b</code></dt>
|
||
<dd>Matches either (OR)</dd>
|
||
<dt><code>^IFA | ^IFB</code></dt>
|
||
<dd>Starts with IFA or IFB</dd>
|
||
<dt><code>!^~</code></dt>
|
||
<dd>Does not start with ~ (excludes drafts)</dd>
|
||
<dt><code>el.*spc</code></dt>
|
||
<dd>Regex: contains "el" followed by "spc" (use <code>.</code> for any char, <code>.*</code> for any sequence)</dd>
|
||
<dt><code>[ei]fa</code></dt>
|
||
<dd>Regex character class: matches "efa" or "ifa"</dd>
|
||
</dl>
|
||
|
||
<h3>Saving Files</h3>
|
||
<dl>
|
||
<dt>Save All</dt>
|
||
<dd>Renames all modified files in one operation. Confirms before proceeding.</dd>
|
||
<dt>Cancel All</dt>
|
||
<dd>Reverts all unsaved edits back to the original filenames.</dd>
|
||
<dt>SHA256</dt>
|
||
<dd>Enable to compute a cryptographic hash of each file. Use <strong>Export Hashes</strong> to save a <code>sha256sum</code>-compatible file.</dd>
|
||
</dl>
|
||
|
||
<h3>ZDDC Filename Format</h3>
|
||
<p>The required format is:</p>
|
||
<p><code>TRACKINGNUMBER_REVISION (STATUS) - Title.ext</code></p>
|
||
<p>Example: <code>123456-EL-SPC-2623_A (IFR) - Electrical Specification.pdf</code></p>
|
||
<p>Valid statuses: IFA, IFB, IFC, IFD, IFI, IFP, IFR, IFU, REC, RSA, RSB, RSC, RSD, RSI, ---</p>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<script>
|
||
/**
|
||
* ZDDC — shared naming convention library
|
||
*
|
||
* Canonical implementation of all ZDDC filename, folder name, tracking number,
|
||
* revision, and status logic. Included in every tool's build via shared/zddc.js.
|
||
*
|
||
* Exposed as window.zddc (plain global) so it works with every tool's module
|
||
* pattern (archive globals, classifier IIFE, transmittal IIFE, mdedit globals).
|
||
*
|
||
* Public API
|
||
* ----------
|
||
* zddc.parseFilename(str) → ParsedFile | null
|
||
* zddc.parseFolder(str) → ParsedFolder | null
|
||
* zddc.parseRevision(str) → ParsedRevision
|
||
* zddc.formatFilename(parts) → string
|
||
* zddc.formatFolder(parts) → string
|
||
* zddc.compareRevisions(a, b) → number (-1 | 0 | 1)
|
||
* zddc.isValidStatus(str) → boolean
|
||
* zddc.STATUSES → string[]
|
||
*
|
||
* ParsedFile { trackingNumber, revision, status, title, extension }
|
||
* ParsedFolder { date, trackingNumber, status, title }
|
||
* ParsedRevision { base, modifier, modifierType, modifierNumber, isDraft, full }
|
||
*/
|
||
|
||
(function (root) {
|
||
'use strict';
|
||
|
||
// ── Valid status codes ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Complete list of valid ZDDC document status codes.
|
||
* '---' denotes an unknown or not-yet-assigned status.
|
||
*/
|
||
var STATUSES = [
|
||
'---',
|
||
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
||
'REC',
|
||
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
||
];
|
||
|
||
var STATUS_SET = {};
|
||
for (var _i = 0; _i < STATUSES.length; _i++) {
|
||
STATUS_SET[STATUSES[_i]] = true;
|
||
}
|
||
|
||
function isValidStatus(str) {
|
||
return !!STATUS_SET[str];
|
||
}
|
||
|
||
// ── Filename parsing ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Canonical file regex.
|
||
* Matches: TRACKING_REVISION (STATUS) - TITLE.EXT
|
||
*
|
||
* Tracking number: no underscores, no whitespace.
|
||
* Revision: no whitespace, no parentheses.
|
||
* Status: anything inside parentheses (validated separately).
|
||
* Title: everything up to the last dot.
|
||
* Extension: after the last dot (lowercased by parseFilename).
|
||
*/
|
||
var FILE_RE = /^([^_\s]+)_([^\s()_]+)\s*\(([^)]+)\)\s*-\s*(\S.*\S|\S)\.\s*([^\s.]+)$/;
|
||
|
||
/**
|
||
* Parse a ZDDC filename.
|
||
*
|
||
* @param {string} filename
|
||
* @returns {{ trackingNumber: string, revision: string, status: string,
|
||
* title: string, extension: string, valid: boolean } | null}
|
||
* null only if filename is falsy.
|
||
* `valid` is true when all fields matched the ZDDC pattern.
|
||
*/
|
||
function parseFilename(filename) {
|
||
if (!filename) { return null; }
|
||
|
||
var match = filename.match(FILE_RE);
|
||
|
||
if (!match) {
|
||
var lastDot = filename.lastIndexOf('.');
|
||
return {
|
||
trackingNumber: '',
|
||
revision: '',
|
||
status: '',
|
||
title: lastDot > 0 ? filename.substring(0, lastDot) : filename,
|
||
extension: lastDot > 0 ? filename.substring(lastDot + 1).toLowerCase() : '',
|
||
valid: false,
|
||
};
|
||
}
|
||
|
||
return {
|
||
trackingNumber: match[1].trim(),
|
||
revision: match[2].trim(),
|
||
status: match[3].trim(),
|
||
title: match[4].trim(),
|
||
extension: match[5].toLowerCase(),
|
||
valid: true,
|
||
};
|
||
}
|
||
|
||
// ── Folder name parsing ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Transmittal folder regex.
|
||
* Matches: YYYY-MM-DD_TRACKING (STATUS) - TITLE
|
||
*/
|
||
var FOLDER_RE = /^(\d{4}-\d{2}-\d{2})_([^_\s(]+)\s*\(([^)]+)\)\s*-\s*(.+)$/;
|
||
|
||
/**
|
||
* Parse a ZDDC transmittal folder name.
|
||
*
|
||
* @param {string} foldername
|
||
* @returns {{ date: string, trackingNumber: string, status: string,
|
||
* title: string, valid: boolean } | null}
|
||
* null only if foldername is falsy.
|
||
*/
|
||
function parseFolder(foldername) {
|
||
if (!foldername) { return null; }
|
||
|
||
var match = foldername.match(FOLDER_RE);
|
||
|
||
if (!match) {
|
||
return {
|
||
date: '',
|
||
trackingNumber: '',
|
||
status: '',
|
||
title: foldername,
|
||
valid: false,
|
||
};
|
||
}
|
||
|
||
return {
|
||
date: match[1],
|
||
trackingNumber: match[2].trim(),
|
||
status: match[3].trim(),
|
||
title: match[4].trim(),
|
||
valid: true,
|
||
};
|
||
}
|
||
|
||
// ── Revision parsing ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Modifier sub-regex: +LETTER DIGITS e.g. +C1, +B2, +N1, +Q1
|
||
* The draft prefix (~) may appear inside the modifier: A+~C1
|
||
*/
|
||
var MODIFIER_RE = /^\+(~?)([A-Za-z])(\d+)$/;
|
||
|
||
/**
|
||
* Parse a ZDDC revision string.
|
||
*
|
||
* Revision grammar:
|
||
* revision = ['~'] base ['+' ['~'] modifier_letter modifier_number]
|
||
* base = letter(s) | digit(s) | date(YYYY-MM-DD)
|
||
* modifier = letter + digits e.g. C1, B2, N1, Q1
|
||
*
|
||
* @param {string} revision
|
||
* @returns {{
|
||
* base: string,
|
||
* modifier: string, full modifier string e.g. '+C1', '' if none
|
||
* modifierType: string, modifier letter e.g. 'C', '' if none
|
||
* modifierNumber: number, modifier number e.g. 1, 0 if none
|
||
* modifierIsDraft: boolean,
|
||
* isDraft: boolean, true if base revision starts with ~
|
||
* full: string, original input
|
||
* }}
|
||
*/
|
||
function parseRevision(revision) {
|
||
var raw = (revision || '').toString();
|
||
|
||
// Split on '+' to separate base from optional modifier
|
||
var plusIdx = raw.indexOf('+');
|
||
var basePart = plusIdx === -1 ? raw : raw.substring(0, plusIdx);
|
||
var modifierPart = plusIdx === -1 ? '' : raw.substring(plusIdx);
|
||
|
||
// Draft flag on the base part
|
||
var isDraft = basePart.startsWith('~');
|
||
var base = isDraft ? basePart.substring(1) : basePart;
|
||
|
||
// Parse modifier
|
||
var modifier = '';
|
||
var modifierType = '';
|
||
var modifierNumber = 0;
|
||
var modifierIsDraft = false;
|
||
|
||
if (modifierPart) {
|
||
var mMatch = modifierPart.match(MODIFIER_RE);
|
||
if (mMatch) {
|
||
modifierIsDraft = mMatch[1] === '~';
|
||
modifierType = mMatch[2].toUpperCase();
|
||
modifierNumber = parseInt(mMatch[3], 10);
|
||
modifier = modifierPart;
|
||
} else {
|
||
// Unrecognised modifier — preserve as-is
|
||
modifier = modifierPart;
|
||
}
|
||
}
|
||
|
||
return {
|
||
base: base,
|
||
modifier: modifier,
|
||
modifierType: modifierType,
|
||
modifierNumber: modifierNumber,
|
||
modifierIsDraft: modifierIsDraft,
|
||
isDraft: isDraft,
|
||
full: raw,
|
||
};
|
||
}
|
||
|
||
// ── Revision comparison ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Classify a base revision string into a sort tier:
|
||
* 0 = date (YYYY-MM-DD)
|
||
* 1 = letter(s) A, B, AA …
|
||
* 2 = number(s) 0, 1, 2, 1.5 …
|
||
* 3 = other
|
||
*/
|
||
function _baseTier(base) {
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(base)) { return 0; }
|
||
if (/^[A-Za-z]+$/.test(base)) { return 1; }
|
||
if (/^\d+(\.\d+)?$/.test(base)) { return 2; }
|
||
return 3;
|
||
}
|
||
|
||
/**
|
||
* Compare two base revision strings.
|
||
* Sort order: dates < letters < numbers < other.
|
||
*/
|
||
function _compareBase(a, b) {
|
||
var ta = _baseTier(a);
|
||
var tb = _baseTier(b);
|
||
if (ta !== tb) { return ta - tb; }
|
||
|
||
if (ta === 0) { return a < b ? -1 : a > b ? 1 : 0; } // date lexicographic = chronological
|
||
if (ta === 1) { return a.toUpperCase() < b.toUpperCase() ? -1 : a.toUpperCase() > b.toUpperCase() ? 1 : 0; }
|
||
if (ta === 2) { return parseFloat(a) - parseFloat(b); }
|
||
return a.localeCompare(b);
|
||
}
|
||
|
||
/**
|
||
* Compare two ZDDC revision strings for sort ordering.
|
||
*
|
||
* Canonical order (ascending = older → newer):
|
||
* ~A < A < A+B1 < A+C1 < A+~C2 < A+C2 < A+N1 < A+Q1
|
||
* < ~B < B < … < 0 < 1 < 2
|
||
*
|
||
* Rules:
|
||
* 1. Compare base revisions first (dates < letters < numbers).
|
||
* 2. For equal bases, draft (isDraft=true) comes before final.
|
||
* 3. For equal base+draft, no-modifier < has-modifier.
|
||
* 4. For equal base+draft+modifier presence:
|
||
* a. modifier draft comes before modifier final (modifierIsDraft).
|
||
* b. Sort modifier by type letter then by number.
|
||
*
|
||
* @param {string} a
|
||
* @param {string} b
|
||
* @returns {number} negative if a < b, 0 if equal, positive if a > b
|
||
*/
|
||
function compareRevisions(a, b) {
|
||
var pa = parseRevision(a);
|
||
var pb = parseRevision(b);
|
||
|
||
// 1. Base revision
|
||
var baseCmp = _compareBase(pa.base, pb.base);
|
||
if (baseCmp !== 0) { return baseCmp; }
|
||
|
||
// 2. Draft before final (for same base)
|
||
if (pa.isDraft !== pb.isDraft) { return pa.isDraft ? -1 : 1; }
|
||
|
||
// 3. No modifier before any modifier
|
||
var aHasMod = pa.modifier !== '';
|
||
var bHasMod = pb.modifier !== '';
|
||
if (aHasMod !== bHasMod) { return aHasMod ? 1 : -1; }
|
||
|
||
if (!aHasMod) { return 0; } // both have no modifier
|
||
|
||
// 4. Compare modifiers: type → number → draft (draft is a tie-breaker only)
|
||
// 4a. Modifier type letter (B < C < N < Q …)
|
||
if (pa.modifierType !== pb.modifierType) {
|
||
return pa.modifierType < pb.modifierType ? -1 : 1;
|
||
}
|
||
|
||
// 4b. Modifier number (1 < 2 …)
|
||
if (pa.modifierNumber !== pb.modifierNumber) {
|
||
return pa.modifierNumber - pb.modifierNumber;
|
||
}
|
||
|
||
// 4c. Draft of a modifier comes before the final modifier (same type+number)
|
||
if (pa.modifierIsDraft !== pb.modifierIsDraft) {
|
||
return pa.modifierIsDraft ? -1 : 1;
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
// ── Filename / folder formatting ─────────────────────────────────────────
|
||
|
||
/**
|
||
* Build a ZDDC filename from its components.
|
||
*
|
||
* @param {{ trackingNumber: string, revision: string, status: string,
|
||
* title: string, extension: string }} parts
|
||
* @returns {string} e.g. "123456-EL-SPC-2623_A (IFR) - Specification.pdf"
|
||
*/
|
||
function formatFilename(parts) {
|
||
var tn = (parts.trackingNumber || '').trim();
|
||
var rev = (parts.revision || '').trim();
|
||
var st = (parts.status || '').trim();
|
||
var ttl = (parts.title || '').trim();
|
||
var ext = (parts.extension || '').replace(/^\./, '');
|
||
|
||
if (!tn || !rev || !st || !ttl) { return ''; }
|
||
|
||
var name = tn + '_' + rev + ' (' + st + ') - ' + ttl;
|
||
return ext ? name + '.' + ext : name;
|
||
}
|
||
|
||
/**
|
||
* Build a ZDDC transmittal folder name from its components.
|
||
*
|
||
* @param {{ date: string, trackingNumber: string, status: string,
|
||
* title: string }} parts
|
||
* @returns {string} e.g. "2025-10-31_123456-EM-SUB-0001 (IFR) - Title"
|
||
*/
|
||
function formatFolder(parts) {
|
||
var dt = (parts.date || '').trim();
|
||
var tn = (parts.trackingNumber || '').trim();
|
||
var st = (parts.status || '').trim();
|
||
var ttl = (parts.title || '').trim();
|
||
|
||
if (!dt || !tn || !st || !ttl) { return ''; }
|
||
|
||
return dt + '_' + tn + ' (' + st + ') - ' + ttl;
|
||
}
|
||
|
||
// ── Filename / extension splitting ───────────────────────────────────────
|
||
|
||
/**
|
||
* Split a filename into its base name and extension (no leading dot).
|
||
* Treats leading dot ('.gitignore') as no extension.
|
||
*
|
||
* @param {string} filename
|
||
* @returns {{ name: string, extension: string }}
|
||
*/
|
||
function splitExtension(filename) {
|
||
if (!filename) { return { name: '', extension: '' }; }
|
||
var lastDot = filename.lastIndexOf('.');
|
||
if (lastDot <= 0) { return { name: filename, extension: '' }; }
|
||
return {
|
||
name: filename.substring(0, lastDot),
|
||
extension: filename.substring(lastDot + 1).toLowerCase(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Join a base name and extension. Tolerant of either form ('pdf' or '.pdf').
|
||
* Returns just the name when extension is empty.
|
||
*/
|
||
function joinExtension(name, extension) {
|
||
var ext = (extension || '').replace(/^\./, '');
|
||
return ext ? name + '.' + ext : name;
|
||
}
|
||
|
||
// ── Public API ───────────────────────────────────────────────────────────
|
||
|
||
root.zddc = {
|
||
STATUSES: STATUSES,
|
||
isValidStatus: isValidStatus,
|
||
parseFilename: parseFilename,
|
||
parseFolder: parseFolder,
|
||
parseRevision: parseRevision,
|
||
formatFilename: formatFilename,
|
||
formatFolder: formatFolder,
|
||
compareRevisions: compareRevisions,
|
||
splitExtension: splitExtension,
|
||
joinExtension: joinExtension,
|
||
};
|
||
|
||
}(typeof window !== 'undefined' ? window : this));
|
||
|
||
/**
|
||
* ZDDC — shared SHA-256 helpers
|
||
*
|
||
* Attaches to window.zddc.crypto. Must load AFTER shared/zddc.js (which creates
|
||
* the window.zddc object).
|
||
*
|
||
* Exports:
|
||
* zddc.crypto.sha256Hex(buffer) → Promise<string> hex digest of ArrayBuffer/Uint8Array
|
||
* zddc.crypto.sha256String(str) → Promise<string> hex digest of UTF-8 encoded string
|
||
* zddc.crypto.sha256File(file, onProgress?) → Promise<string>
|
||
* chunked streaming digest for File/Blob; for files >= 4 MB, streams 2 MB chunks
|
||
* and invokes onProgress(loaded, total) every ~8 MB.
|
||
* zddc.crypto.bytesToHex(buffer) → string (hex of ArrayBuffer/Uint8Array, no digest)
|
||
*
|
||
* Throws if Web Crypto SubtleCrypto is not available.
|
||
*/
|
||
(function (root) {
|
||
'use strict';
|
||
|
||
if (!root.zddc) {
|
||
throw new Error('shared/hash.js: window.zddc must be loaded first');
|
||
}
|
||
|
||
var HASH_CHUNK_SIZE = 2 * 1024 * 1024; // 2 MB
|
||
|
||
function requireSubtle() {
|
||
if (!root.crypto || !root.crypto.subtle || typeof root.crypto.subtle.digest !== 'function') {
|
||
throw new Error('Web Crypto SubtleCrypto is required');
|
||
}
|
||
}
|
||
|
||
function bytesToHex(buffer) {
|
||
return Array.from(new Uint8Array(buffer), function (byte) {
|
||
return byte.toString(16).padStart(2, '0');
|
||
}).join('');
|
||
}
|
||
|
||
async function sha256Hex(buffer) {
|
||
requireSubtle();
|
||
var input = (buffer instanceof Uint8Array) ? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) : buffer;
|
||
var hash = await root.crypto.subtle.digest('SHA-256', input);
|
||
return bytesToHex(hash);
|
||
}
|
||
|
||
async function sha256String(str) {
|
||
requireSubtle();
|
||
var bytes = new TextEncoder().encode(str);
|
||
var hash = await root.crypto.subtle.digest('SHA-256', bytes);
|
||
return bytesToHex(hash);
|
||
}
|
||
|
||
async function sha256File(file, onProgress) {
|
||
requireSubtle();
|
||
// Single-shot for small files or environments without ReadableStream
|
||
if (file.size < HASH_CHUNK_SIZE * 2 || typeof file.stream !== 'function') {
|
||
if (onProgress) { onProgress(file.size, file.size); }
|
||
var buf = await file.arrayBuffer();
|
||
var hash = await root.crypto.subtle.digest('SHA-256', buf);
|
||
return bytesToHex(hash);
|
||
}
|
||
// Chunked streaming for large files
|
||
var reader = file.stream().getReader();
|
||
var loaded = 0;
|
||
var chunks = [];
|
||
var yieldCounter = 0;
|
||
while (true) {
|
||
var result = await reader.read();
|
||
if (result.done) { break; }
|
||
chunks.push(result.value);
|
||
loaded += result.value.byteLength;
|
||
yieldCounter++;
|
||
if (onProgress && yieldCounter % 4 === 0) {
|
||
onProgress(loaded, file.size);
|
||
await new Promise(function (r) { setTimeout(r, 0); });
|
||
}
|
||
}
|
||
var total = new Uint8Array(loaded);
|
||
var offset = 0;
|
||
for (var i = 0; i < chunks.length; i++) {
|
||
total.set(chunks[i], offset);
|
||
offset += chunks[i].byteLength;
|
||
}
|
||
var digest = await root.crypto.subtle.digest('SHA-256', total.buffer);
|
||
if (onProgress) { onProgress(file.size, file.size); }
|
||
return bytesToHex(digest);
|
||
}
|
||
|
||
root.zddc.crypto = {
|
||
sha256Hex: sha256Hex,
|
||
sha256String: sha256String,
|
||
sha256File: sha256File,
|
||
bytesToHex: bytesToHex,
|
||
};
|
||
})(typeof window !== 'undefined' ? window : globalThis);
|
||
|
||
/**
|
||
* ZDDC shared theme toggle — light / dark / auto.
|
||
* Persists choice to localStorage under 'zddc-theme'.
|
||
* Works with all four tools regardless of their module pattern.
|
||
* Expects: #theme-btn in the DOM (optional — skips gracefully if absent).
|
||
*
|
||
* Theme cycle: auto → light → dark → auto …
|
||
* 'auto' honours the OS prefers-color-scheme media query (CSS handles it).
|
||
* 'light' sets data-theme="light" on <html> (overrides dark media query).
|
||
* 'dark' sets data-theme="dark" on <html>.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
var STORAGE_KEY = 'zddc-theme';
|
||
var THEMES = ['auto', 'light', 'dark'];
|
||
|
||
var LABELS = {
|
||
auto: '◐',
|
||
light: '☀',
|
||
dark: '☾'
|
||
};
|
||
|
||
var TITLES = {
|
||
auto: 'Theme: auto (follows OS)',
|
||
light: 'Theme: light',
|
||
dark: 'Theme: dark'
|
||
};
|
||
|
||
function load() {
|
||
var stored = localStorage.getItem(STORAGE_KEY);
|
||
return THEMES.indexOf(stored) !== -1 ? stored : 'auto';
|
||
}
|
||
|
||
function apply(theme) {
|
||
if (theme === 'dark') {
|
||
document.documentElement.setAttribute('data-theme', 'dark');
|
||
} else if (theme === 'light') {
|
||
document.documentElement.setAttribute('data-theme', 'light');
|
||
} else {
|
||
document.documentElement.removeAttribute('data-theme');
|
||
}
|
||
}
|
||
|
||
function save(theme) {
|
||
try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
|
||
}
|
||
|
||
function updateButton(btn, theme) {
|
||
btn.textContent = LABELS[theme];
|
||
btn.title = TITLES[theme];
|
||
btn.setAttribute('aria-label', TITLES[theme]);
|
||
}
|
||
|
||
function next(theme) {
|
||
return THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
|
||
}
|
||
|
||
function init() {
|
||
var current = load();
|
||
apply(current);
|
||
|
||
var btn = document.getElementById('theme-btn');
|
||
if (!btn) { return; }
|
||
|
||
updateButton(btn, current);
|
||
|
||
btn.addEventListener('click', function () {
|
||
current = next(current);
|
||
apply(current);
|
||
save(current);
|
||
updateButton(btn, current);
|
||
});
|
||
}
|
||
|
||
/* Apply theme immediately (before DOM ready) to avoid flash */
|
||
apply(load());
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
}());
|
||
|
||
/**
|
||
* ZDDC — shared preview helpers
|
||
*
|
||
* Cross-tool helpers for previewing file types that need a decoder:
|
||
* - TIFF (UTIF.js) — multi-page, browser-PDF-viewer-style toolbar
|
||
* - ZIP listing (JSZip) — sortable file-list view
|
||
*
|
||
* Renderers operate on any document (parent window or popup window), so the
|
||
* same code works for tools whose preview opens in a popup (classifier,
|
||
* archive, transmittal) and tools that render inline (mdedit).
|
||
*
|
||
* Public API on window.zddc.preview:
|
||
* loadLibrary(url) → Promise<void>
|
||
* renderTiff(doc, container, arrayBuffer, opts) → Promise<void>
|
||
* renderZipListing(doc, container, arrayBuffer, opts) → Promise<void>
|
||
* TIFF_EXTENSIONS, IMAGE_EXTENSIONS, TEXT_EXTENSIONS, OFFICE_EXTENSIONS
|
||
* isTiff(ext), isImage(ext), isText(ext), isZip(ext), isOffice(ext)
|
||
*
|
||
* Each tool keeps its own dispatcher; this lib only owns the heavy renderers.
|
||
*/
|
||
|
||
(function (root) {
|
||
'use strict';
|
||
|
||
var TIFF_EXTENSIONS = ['tif', 'tiff'];
|
||
var IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'];
|
||
var TEXT_EXTENSIONS = [
|
||
'txt', 'md', 'markdown', 'json', 'xml', 'csv', 'tsv', 'log',
|
||
'html', 'htm', 'css', 'js', 'mjs', 'ts', 'tsx', 'jsx',
|
||
'py', 'rb', 'sh', 'bash', 'zsh', 'bat', 'ps1',
|
||
'yaml', 'yml', 'ini', 'cfg', 'conf', 'toml',
|
||
'c', 'cc', 'cpp', 'h', 'hpp', 'go', 'rs', 'java', 'kt',
|
||
'sql', 'env'
|
||
];
|
||
var OFFICE_EXTENSIONS = ['docx', 'xlsx', 'xls'];
|
||
|
||
function lowerExt(ext) { return (ext || '').toLowerCase(); }
|
||
function isTiff(ext) { return TIFF_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
|
||
function isImage(ext) { return IMAGE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
|
||
function isText(ext) { return TEXT_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
|
||
function isZip(ext) { return lowerExt(ext) === 'zip'; }
|
||
function isOffice(ext) { return OFFICE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
|
||
|
||
// ── CDN library loader (parent window cache) ─────────────────────────────
|
||
|
||
var _libCache = new Map();
|
||
|
||
function loadLibrary(url) {
|
||
if (_libCache.has(url)) return _libCache.get(url);
|
||
var p = new Promise(function (resolve, reject) {
|
||
var s = document.createElement('script');
|
||
s.src = url;
|
||
s.onload = function () { resolve(); };
|
||
s.onerror = function () { reject(new Error('Failed to load: ' + url)); };
|
||
document.head.appendChild(s);
|
||
});
|
||
_libCache.set(url, p);
|
||
return p;
|
||
}
|
||
|
||
// ── Style injection (idempotent per-document) ────────────────────────────
|
||
|
||
function injectStyles(doc, id, css) {
|
||
if (doc.getElementById(id)) return;
|
||
var style = doc.createElement('style');
|
||
style.id = id;
|
||
style.textContent = css;
|
||
doc.head.appendChild(style);
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||
|
||
function formatSize(bytes) {
|
||
if (bytes == null) return '';
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||
}
|
||
|
||
function formatDate(d) {
|
||
if (!d) return '';
|
||
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
||
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ── TIFF renderer ────────────────────────────────────────────────────────
|
||
|
||
var TIFF_CSS =
|
||
'.tiff-toolbar{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;' +
|
||
'background:#f5f5f5;border-bottom:1px solid #ddd;flex-wrap:wrap;font-size:.85rem;}' +
|
||
'.tiff-toolbar .tiff-btn{padding:.25rem .55rem;border:1px solid #ccc;border-radius:3px;' +
|
||
'background:#fff;cursor:pointer;font-size:.85rem;line-height:1;min-width:1.8rem;}' +
|
||
'.tiff-toolbar .tiff-btn:hover:not(:disabled){background:#e8e8e8;}' +
|
||
'.tiff-toolbar .tiff-btn:disabled{opacity:.4;cursor:default;}' +
|
||
'.tiff-toolbar .tiff-page-info{display:inline-flex;align-items:center;gap:.3rem;}' +
|
||
'.tiff-toolbar .tiff-page-input{width:3.2rem;padding:.2rem .3rem;border:1px solid #ccc;' +
|
||
'border-radius:3px;text-align:center;font-size:.85rem;}' +
|
||
'.tiff-toolbar .tiff-zoom-select{padding:.2rem .3rem;border:1px solid #ccc;border-radius:3px;' +
|
||
'background:#fff;font-size:.85rem;}' +
|
||
'.tiff-toolbar .tiff-spacer{flex:1;}' +
|
||
'.tiff-viewport{flex:1;overflow:auto;background:#525659;display:flex;align-items:flex-start;' +
|
||
'justify-content:center;padding:1rem;}' +
|
||
'.tiff-canvas{background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.4);display:block;' +
|
||
'image-rendering:auto;}' +
|
||
'.tiff-error{flex:1;display:flex;align-items:center;justify-content:center;color:#900;' +
|
||
'padding:2rem;text-align:center;}';
|
||
|
||
function renderTiff(doc, container, arrayBuffer, opts) {
|
||
opts = opts || {};
|
||
injectStyles(doc, 'zddc-tiff-styles', TIFF_CSS);
|
||
|
||
return loadLibrary('https://cdn.jsdelivr.net/npm/utif@3.1.0/UTIF.js').then(function () {
|
||
var ifds;
|
||
try {
|
||
ifds = window.UTIF.decode(arrayBuffer);
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="tiff-error">Failed to parse TIFF: '
|
||
+ escapeHtml(e.message || e) + '</div>';
|
||
return;
|
||
}
|
||
if (!ifds || !ifds.length) {
|
||
container.innerHTML = '<div class="tiff-error">No images found in TIFF.</div>';
|
||
return;
|
||
}
|
||
|
||
// Reset container to a flex column
|
||
container.innerHTML = '';
|
||
container.style.display = 'flex';
|
||
container.style.flexDirection = 'column';
|
||
container.style.minHeight = '0';
|
||
container.style.height = '100%';
|
||
container.style.overflow = 'hidden';
|
||
|
||
// Toolbar
|
||
var toolbar = doc.createElement('div');
|
||
toolbar.className = 'tiff-toolbar';
|
||
|
||
var btnPrev = doc.createElement('button');
|
||
btnPrev.className = 'tiff-btn'; btnPrev.type = 'button';
|
||
btnPrev.title = 'Previous page'; btnPrev.textContent = '◀';
|
||
|
||
var pageInfo = doc.createElement('span');
|
||
pageInfo.className = 'tiff-page-info';
|
||
var pageInput = doc.createElement('input');
|
||
pageInput.type = 'number'; pageInput.min = '1'; pageInput.value = '1';
|
||
pageInput.className = 'tiff-page-input';
|
||
var pageOf = doc.createElement('span');
|
||
pageOf.textContent = ' of ' + ifds.length;
|
||
pageInfo.appendChild(doc.createTextNode('Page '));
|
||
pageInfo.appendChild(pageInput);
|
||
pageInfo.appendChild(pageOf);
|
||
|
||
var btnNext = doc.createElement('button');
|
||
btnNext.className = 'tiff-btn'; btnNext.type = 'button';
|
||
btnNext.title = 'Next page'; btnNext.textContent = '▶';
|
||
|
||
var spacer = doc.createElement('span');
|
||
spacer.className = 'tiff-spacer';
|
||
|
||
var btnZoomOut = doc.createElement('button');
|
||
btnZoomOut.className = 'tiff-btn'; btnZoomOut.type = 'button';
|
||
btnZoomOut.title = 'Zoom out'; btnZoomOut.textContent = '−';
|
||
|
||
var zoomSelect = doc.createElement('select');
|
||
zoomSelect.className = 'tiff-zoom-select';
|
||
var zoomOptions = [
|
||
['fit-width', 'Fit width'],
|
||
['fit-page', 'Fit page'],
|
||
['0.5', '50%'],
|
||
['0.75', '75%'],
|
||
['1', '100%'],
|
||
['1.25', '125%'],
|
||
['1.5', '150%'],
|
||
['2', '200%'],
|
||
['3', '300%'],
|
||
['4', '400%']
|
||
];
|
||
zoomOptions.forEach(function (z) {
|
||
var o = doc.createElement('option');
|
||
o.value = z[0]; o.textContent = z[1];
|
||
zoomSelect.appendChild(o);
|
||
});
|
||
zoomSelect.value = 'fit-width';
|
||
|
||
var btnZoomIn = doc.createElement('button');
|
||
btnZoomIn.className = 'tiff-btn'; btnZoomIn.type = 'button';
|
||
btnZoomIn.title = 'Zoom in'; btnZoomIn.textContent = '+';
|
||
|
||
toolbar.appendChild(btnPrev);
|
||
toolbar.appendChild(pageInfo);
|
||
toolbar.appendChild(btnNext);
|
||
toolbar.appendChild(spacer);
|
||
toolbar.appendChild(btnZoomOut);
|
||
toolbar.appendChild(zoomSelect);
|
||
toolbar.appendChild(btnZoomIn);
|
||
|
||
// Viewport with canvas
|
||
var viewport = doc.createElement('div');
|
||
viewport.className = 'tiff-viewport';
|
||
var canvas = doc.createElement('canvas');
|
||
canvas.className = 'tiff-canvas';
|
||
viewport.appendChild(canvas);
|
||
|
||
container.appendChild(toolbar);
|
||
container.appendChild(viewport);
|
||
|
||
// Render state
|
||
var currentPage = 0;
|
||
var zoom = 1;
|
||
var fitMode = 'width'; // 'width' | 'page' | null
|
||
var decoded = new Array(ifds.length);
|
||
|
||
function decodePage(i) {
|
||
if (decoded[i]) return decoded[i];
|
||
var ifd = ifds[i];
|
||
window.UTIF.decodeImage(arrayBuffer, ifd);
|
||
var rgba = window.UTIF.toRGBA8(ifd);
|
||
decoded[i] = { rgba: rgba, w: ifd.width, h: ifd.height };
|
||
return decoded[i];
|
||
}
|
||
|
||
function applyZoom() {
|
||
var page = decoded[currentPage];
|
||
if (!page) return;
|
||
var availW = viewport.clientWidth - 32; // padding
|
||
var availH = viewport.clientHeight - 32;
|
||
var scale;
|
||
if (fitMode === 'width') {
|
||
scale = availW / page.w;
|
||
} else if (fitMode === 'page') {
|
||
scale = Math.min(availW / page.w, availH / page.h);
|
||
} else {
|
||
scale = zoom;
|
||
}
|
||
if (!isFinite(scale) || scale <= 0) scale = 1;
|
||
canvas.style.width = (page.w * scale) + 'px';
|
||
canvas.style.height = (page.h * scale) + 'px';
|
||
}
|
||
|
||
function renderPage() {
|
||
var page;
|
||
try {
|
||
page = decodePage(currentPage);
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="tiff-error">Failed to decode page '
|
||
+ (currentPage + 1) + ': ' + escapeHtml(e.message || e) + '</div>';
|
||
return;
|
||
}
|
||
canvas.width = page.w;
|
||
canvas.height = page.h;
|
||
var ctx = canvas.getContext('2d');
|
||
var imgData = ctx.createImageData(page.w, page.h);
|
||
imgData.data.set(page.rgba);
|
||
ctx.putImageData(imgData, 0, 0);
|
||
applyZoom();
|
||
pageInput.value = String(currentPage + 1);
|
||
btnPrev.disabled = currentPage <= 0;
|
||
btnNext.disabled = currentPage >= ifds.length - 1;
|
||
}
|
||
|
||
function setZoomFromSelect() {
|
||
var v = zoomSelect.value;
|
||
if (v === 'fit-width') { fitMode = 'width'; }
|
||
else if (v === 'fit-page') { fitMode = 'page'; }
|
||
else { fitMode = null; zoom = parseFloat(v) || 1; }
|
||
applyZoom();
|
||
}
|
||
|
||
function nudgeZoom(factor) {
|
||
if (fitMode) {
|
||
// capture current effective scale before leaving fit mode
|
||
var page = decoded[currentPage];
|
||
if (page) {
|
||
var availW = viewport.clientWidth - 32;
|
||
var availH = viewport.clientHeight - 32;
|
||
zoom = fitMode === 'width'
|
||
? availW / page.w
|
||
: Math.min(availW / page.w, availH / page.h);
|
||
} else {
|
||
zoom = 1;
|
||
}
|
||
fitMode = null;
|
||
}
|
||
zoom = Math.max(0.1, Math.min(8, zoom * factor));
|
||
// Match select option if any are close, else show as percent
|
||
var matched = false;
|
||
for (var i = 0; i < zoomSelect.options.length; i++) {
|
||
var ov = zoomSelect.options[i].value;
|
||
if (ov !== 'fit-width' && ov !== 'fit-page' && Math.abs(parseFloat(ov) - zoom) < 0.001) {
|
||
zoomSelect.value = ov; matched = true; break;
|
||
}
|
||
}
|
||
if (!matched) {
|
||
// Nearest standard step
|
||
var best = '1', bestDiff = Infinity;
|
||
for (var j = 0; j < zoomSelect.options.length; j++) {
|
||
var v2 = zoomSelect.options[j].value;
|
||
if (v2 === 'fit-width' || v2 === 'fit-page') continue;
|
||
var diff = Math.abs(parseFloat(v2) - zoom);
|
||
if (diff < bestDiff) { bestDiff = diff; best = v2; }
|
||
}
|
||
zoom = parseFloat(best);
|
||
zoomSelect.value = best;
|
||
}
|
||
applyZoom();
|
||
}
|
||
|
||
btnPrev.addEventListener('click', function () {
|
||
if (currentPage > 0) { currentPage--; renderPage(); }
|
||
});
|
||
btnNext.addEventListener('click', function () {
|
||
if (currentPage < ifds.length - 1) { currentPage++; renderPage(); }
|
||
});
|
||
pageInput.addEventListener('change', function () {
|
||
var n = parseInt(pageInput.value, 10);
|
||
if (!isNaN(n) && n >= 1 && n <= ifds.length) {
|
||
currentPage = n - 1;
|
||
renderPage();
|
||
} else {
|
||
pageInput.value = String(currentPage + 1);
|
||
}
|
||
});
|
||
zoomSelect.addEventListener('change', setZoomFromSelect);
|
||
btnZoomIn.addEventListener('click', function () { nudgeZoom(1.25); });
|
||
btnZoomOut.addEventListener('click', function () { nudgeZoom(1 / 1.25); });
|
||
|
||
// Keyboard nav (only when toolbar/viewport in focus path)
|
||
container.tabIndex = 0;
|
||
container.addEventListener('keydown', function (e) {
|
||
if (e.target === pageInput) return;
|
||
if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
|
||
if (currentPage > 0) { currentPage--; renderPage(); e.preventDefault(); }
|
||
} else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
|
||
if (currentPage < ifds.length - 1) { currentPage++; renderPage(); e.preventDefault(); }
|
||
}
|
||
});
|
||
|
||
// Re-fit on viewport resize
|
||
if (typeof (doc.defaultView && doc.defaultView.ResizeObserver) === 'function') {
|
||
var ro = new doc.defaultView.ResizeObserver(function () { applyZoom(); });
|
||
ro.observe(viewport);
|
||
} else if (doc.defaultView) {
|
||
doc.defaultView.addEventListener('resize', function () { applyZoom(); });
|
||
}
|
||
|
||
renderPage();
|
||
});
|
||
}
|
||
|
||
// ── ZIP listing renderer ─────────────────────────────────────────────────
|
||
|
||
var ZIP_CSS =
|
||
'.zip-header{padding:.4rem .8rem;background:#f5f5f5;border-bottom:1px solid #ddd;' +
|
||
'font-size:.85rem;color:#444;}' +
|
||
'.zip-table-wrap{flex:1;overflow:auto;}' +
|
||
'.zip-table{width:100%;border-collapse:collapse;font-size:.85rem;font-family:' +
|
||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}' +
|
||
'.zip-table thead th{position:sticky;top:0;background:#f0f0f0;text-align:left;' +
|
||
'padding:.4rem .6rem;border-bottom:1px solid #ccc;cursor:pointer;user-select:none;' +
|
||
'font-weight:600;}' +
|
||
'.zip-table thead th:hover{background:#e6e6e6;}' +
|
||
'.zip-table thead th.zip-sort-asc::after{content:" ▲";font-size:.7rem;color:#888;}' +
|
||
'.zip-table thead th.zip-sort-desc::after{content:" ▼";font-size:.7rem;color:#888;}' +
|
||
'.zip-table tbody td{padding:.3rem .6rem;border-bottom:1px solid #eee;}' +
|
||
'.zip-table tbody tr:hover{background:#f6faff;}' +
|
||
'.zip-table .zip-folder{color:#888;}' +
|
||
'.zip-table .zip-name{color:#222;}' +
|
||
'.zip-table .zip-size,.zip-table .zip-date{font-variant-numeric:tabular-nums;' +
|
||
'white-space:nowrap;color:#555;}' +
|
||
'.zip-table .zip-col-size,.zip-table .zip-col-date{text-align:right;}' +
|
||
'.zip-empty{padding:2rem;text-align:center;color:#888;}';
|
||
|
||
function renderZipListing(doc, container, arrayBuffer, opts) {
|
||
opts = opts || {};
|
||
injectStyles(doc, 'zddc-zip-styles', ZIP_CSS);
|
||
|
||
return loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js').then(function () {
|
||
return window.JSZip.loadAsync(arrayBuffer);
|
||
}).then(function (zip) {
|
||
var entries = [];
|
||
zip.forEach(function (relativePath, zipEntry) {
|
||
if (zipEntry.dir) return;
|
||
var size = (zipEntry._data && zipEntry._data.uncompressedSize) || 0;
|
||
entries.push({
|
||
path: relativePath,
|
||
name: relativePath.split('/').pop(),
|
||
size: size,
|
||
modified: zipEntry.date instanceof Date ? zipEntry.date : null
|
||
});
|
||
});
|
||
|
||
container.innerHTML = '';
|
||
container.style.display = 'flex';
|
||
container.style.flexDirection = 'column';
|
||
container.style.minHeight = '0';
|
||
container.style.height = '100%';
|
||
container.style.overflow = 'hidden';
|
||
|
||
var totalSize = entries.reduce(function (s, e) { return s + e.size; }, 0);
|
||
|
||
var header = doc.createElement('div');
|
||
header.className = 'zip-header';
|
||
header.textContent = entries.length + ' file' + (entries.length === 1 ? '' : 's')
|
||
+ (totalSize ? ' · ' + formatSize(totalSize) + ' uncompressed' : '');
|
||
container.appendChild(header);
|
||
|
||
if (!entries.length) {
|
||
var empty = doc.createElement('div');
|
||
empty.className = 'zip-empty';
|
||
empty.textContent = '(empty archive)';
|
||
container.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
var wrap = doc.createElement('div');
|
||
wrap.className = 'zip-table-wrap';
|
||
|
||
var table = doc.createElement('table');
|
||
table.className = 'zip-table';
|
||
var thead = doc.createElement('thead');
|
||
var trh = doc.createElement('tr');
|
||
var cols = [
|
||
{ key: 'path', label: 'Name', cls: 'zip-col-name' },
|
||
{ key: 'size', label: 'Size', cls: 'zip-col-size' },
|
||
{ key: 'modified', label: 'Modified', cls: 'zip-col-date' }
|
||
];
|
||
cols.forEach(function (c) {
|
||
var th = doc.createElement('th');
|
||
th.className = c.cls;
|
||
th.dataset.key = c.key;
|
||
th.textContent = c.label;
|
||
trh.appendChild(th);
|
||
});
|
||
thead.appendChild(trh);
|
||
table.appendChild(thead);
|
||
|
||
var tbody = doc.createElement('tbody');
|
||
table.appendChild(tbody);
|
||
|
||
wrap.appendChild(table);
|
||
container.appendChild(wrap);
|
||
|
||
var sortKey = 'path';
|
||
var sortDir = 1;
|
||
|
||
function render() {
|
||
var sorted = entries.slice().sort(function (a, b) {
|
||
var av, bv;
|
||
if (sortKey === 'size') { av = a.size; bv = b.size; }
|
||
else if (sortKey === 'modified') {
|
||
av = a.modified ? a.modified.getTime() : 0;
|
||
bv = b.modified ? b.modified.getTime() : 0;
|
||
} else {
|
||
av = a.path.toLowerCase(); bv = b.path.toLowerCase();
|
||
}
|
||
if (av < bv) return -1 * sortDir;
|
||
if (av > bv) return 1 * sortDir;
|
||
return 0;
|
||
});
|
||
|
||
tbody.innerHTML = '';
|
||
sorted.forEach(function (e) {
|
||
var tr = doc.createElement('tr');
|
||
var td1 = doc.createElement('td');
|
||
var slash = e.path.lastIndexOf('/');
|
||
if (slash >= 0) {
|
||
var folder = doc.createElement('span');
|
||
folder.className = 'zip-folder';
|
||
folder.textContent = e.path.substring(0, slash + 1);
|
||
td1.appendChild(folder);
|
||
}
|
||
var name = doc.createElement('span');
|
||
name.className = 'zip-name';
|
||
name.textContent = e.name;
|
||
td1.appendChild(name);
|
||
|
||
var td2 = doc.createElement('td');
|
||
td2.className = 'zip-size';
|
||
td2.textContent = formatSize(e.size);
|
||
|
||
var td3 = doc.createElement('td');
|
||
td3.className = 'zip-date';
|
||
td3.textContent = formatDate(e.modified);
|
||
|
||
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
// Update sort arrows
|
||
var ths = thead.querySelectorAll('th');
|
||
for (var i = 0; i < ths.length; i++) {
|
||
ths[i].classList.remove('zip-sort-asc', 'zip-sort-desc');
|
||
if (ths[i].dataset.key === sortKey) {
|
||
ths[i].classList.add(sortDir > 0 ? 'zip-sort-asc' : 'zip-sort-desc');
|
||
}
|
||
}
|
||
}
|
||
|
||
thead.querySelectorAll('th').forEach(function (th) {
|
||
th.addEventListener('click', function () {
|
||
var k = th.dataset.key;
|
||
if (sortKey === k) sortDir = -sortDir;
|
||
else { sortKey = k; sortDir = 1; }
|
||
render();
|
||
});
|
||
});
|
||
|
||
render();
|
||
}).catch(function (err) {
|
||
container.innerHTML = '<div class="zip-empty">Failed to read ZIP: '
|
||
+ escapeHtml(err.message || err) + '</div>';
|
||
});
|
||
}
|
||
|
||
// ── Public API ───────────────────────────────────────────────────────────
|
||
|
||
if (!root.zddc) root.zddc = {};
|
||
root.zddc.preview = {
|
||
TIFF_EXTENSIONS: TIFF_EXTENSIONS,
|
||
IMAGE_EXTENSIONS: IMAGE_EXTENSIONS,
|
||
TEXT_EXTENSIONS: TEXT_EXTENSIONS,
|
||
OFFICE_EXTENSIONS: OFFICE_EXTENSIONS,
|
||
isTiff: isTiff,
|
||
isImage: isImage,
|
||
isText: isText,
|
||
isZip: isZip,
|
||
isOffice: isOffice,
|
||
loadLibrary: loadLibrary,
|
||
renderTiff: renderTiff,
|
||
renderZipListing: renderZipListing,
|
||
formatSize: formatSize,
|
||
formatDate: formatDate
|
||
};
|
||
})(typeof window !== 'undefined' ? window : this);
|
||
|
||
/**
|
||
* ZDDC Classifier - Main Application
|
||
* Spreadsheet-based file renaming with Excel-like formulas
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
// Global application state
|
||
window.app = {
|
||
// File System
|
||
rootHandle: null,
|
||
|
||
// Data
|
||
folderTree: [],
|
||
selectedFolders: new Set(), // Multi-select support
|
||
lastSelectedFolderPath: null,
|
||
hideCompliant: false,
|
||
calculateSha256: false,
|
||
|
||
// DOM elements (populated on init)
|
||
dom: {},
|
||
|
||
// Modules (populated by other files)
|
||
modules: {}
|
||
};
|
||
|
||
/**
|
||
* Initialize the application
|
||
*/
|
||
function init() {
|
||
|
||
|
||
// Check browser compatibility
|
||
if (!checkBrowserCompatibility()) {
|
||
showBrowserWarning();
|
||
return;
|
||
}
|
||
|
||
// Cache DOM elements
|
||
cacheDOMElements();
|
||
|
||
// Set up event listeners
|
||
setupEventListeners();
|
||
|
||
// Show welcome screen
|
||
showWelcomeScreen();
|
||
|
||
|
||
}
|
||
|
||
/**
|
||
* Check if browser supports File System Access API
|
||
*/
|
||
function checkBrowserCompatibility() {
|
||
return 'showDirectoryPicker' in window;
|
||
}
|
||
|
||
/**
|
||
* Show browser compatibility warning
|
||
*/
|
||
function showBrowserWarning() {
|
||
const warning = document.getElementById('browserWarning');
|
||
const selectBtn = document.getElementById('selectDirectoryBtn');
|
||
if (warning) {
|
||
warning.classList.remove('hidden');
|
||
}
|
||
if (selectBtn) {
|
||
selectBtn.disabled = true;
|
||
selectBtn.textContent = 'Browser Not Supported';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cache DOM element references
|
||
*/
|
||
function cacheDOMElements() {
|
||
app.dom = {
|
||
// Screens
|
||
welcomeScreen: document.getElementById('welcomeScreen'),
|
||
mainApp: document.getElementById('mainApp'),
|
||
|
||
// Header buttons
|
||
selectDirectoryBtn: document.getElementById('selectDirectoryBtn'),
|
||
refreshBtn: document.getElementById('refreshBtn'),
|
||
saveAllBtn: document.getElementById('saveAllBtn'),
|
||
cancelAllBtn: document.getElementById('cancelAllBtn'),
|
||
exportHashesBtn: document.getElementById('exportHashesBtn'),
|
||
sha256Checkbox: document.getElementById('sha256Checkbox'),
|
||
hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'),
|
||
|
||
// Folder tree
|
||
folderTree: document.getElementById('folderTree'),
|
||
folderTreePane: document.getElementById('folderTreePane'),
|
||
collapseTreeBtn: document.getElementById('collapseTreeBtn'),
|
||
autoScrollCheckbox: document.getElementById('autoScrollCheckbox'),
|
||
selectedFoldersCount: document.getElementById('selectedFoldersCount'),
|
||
|
||
// Spreadsheet
|
||
spreadsheet: document.getElementById('spreadsheet'),
|
||
spreadsheetBody: document.getElementById('spreadsheetBody'),
|
||
sha256Column: document.getElementById('sha256Column'),
|
||
|
||
// Stats
|
||
totalFiles: document.getElementById('totalFiles'),
|
||
modifiedFiles: document.getElementById('modifiedFiles'),
|
||
errorFiles: document.getElementById('errorFiles'),
|
||
|
||
// Preview
|
||
togglePreviewBtn: document.getElementById('togglePreviewBtn')
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Set up event listeners
|
||
*/
|
||
function setupEventListeners() {
|
||
// Directory selection
|
||
app.dom.selectDirectoryBtn.addEventListener('click', handleSelectDirectory);
|
||
app.dom.refreshBtn.addEventListener('click', handleRefresh);
|
||
|
||
// Drag and drop on welcome screen
|
||
setupWelcomeDragDrop();
|
||
|
||
// Bulk actions
|
||
app.dom.saveAllBtn.addEventListener('click', handleSaveAll);
|
||
app.dom.cancelAllBtn.addEventListener('click', handleCancelAll);
|
||
|
||
// Export hashes
|
||
app.dom.exportHashesBtn.addEventListener('click', handleExportHashes);
|
||
|
||
// SHA256 toggle
|
||
app.dom.sha256Checkbox.addEventListener('change', handleSha256Toggle);
|
||
|
||
// Hide compliant toggle
|
||
app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
|
||
|
||
// Collapse tree button
|
||
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);
|
||
|
||
// Keyboard shortcuts
|
||
document.addEventListener('keydown', handleKeyDown);
|
||
|
||
// Resize handle
|
||
setupResizeHandle();
|
||
}
|
||
|
||
/**
|
||
* Handle collapse/expand folder tree pane
|
||
*/
|
||
function handleCollapseTree() {
|
||
const pane = app.dom.folderTreePane;
|
||
const btn = app.dom.collapseTreeBtn;
|
||
|
||
pane.classList.toggle('collapsed');
|
||
|
||
if (pane.classList.contains('collapsed')) {
|
||
// Clear any inline width from resize handle
|
||
pane.style.width = '';
|
||
btn.textContent = '▶';
|
||
btn.title = 'Expand folder tree';
|
||
} else {
|
||
btn.textContent = '◀';
|
||
btn.title = 'Collapse folder tree';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set up folder tree resize handle
|
||
*/
|
||
function setupResizeHandle() {
|
||
const handle = document.getElementById('treeResizeHandle');
|
||
const pane = document.getElementById('folderTreePane');
|
||
|
||
if (!handle || !pane) return;
|
||
|
||
let isResizing = false;
|
||
let startX = 0;
|
||
let startWidth = 0;
|
||
|
||
handle.addEventListener('mousedown', (e) => {
|
||
isResizing = true;
|
||
startX = e.clientX;
|
||
startWidth = pane.offsetWidth;
|
||
document.body.style.cursor = 'col-resize';
|
||
e.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!isResizing) return;
|
||
|
||
const delta = e.clientX - startX;
|
||
const newWidth = startWidth + delta;
|
||
|
||
// Respect min width only
|
||
if (newWidth >= 150) {
|
||
pane.style.width = newWidth + 'px';
|
||
}
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
if (isResizing) {
|
||
isResizing = false;
|
||
document.body.style.cursor = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Set up drag-and-drop on the welcome screen
|
||
*/
|
||
function setupWelcomeDragDrop() {
|
||
const screen = app.dom.welcomeScreen;
|
||
if (!screen) return;
|
||
|
||
['dragenter', 'dragover'].forEach(evt => {
|
||
screen.addEventListener(evt, (e) => {
|
||
e.preventDefault();
|
||
screen.classList.add('drag-over');
|
||
});
|
||
});
|
||
|
||
['dragleave', 'drop'].forEach(evt => {
|
||
screen.addEventListener(evt, (e) => {
|
||
e.preventDefault();
|
||
screen.classList.remove('drag-over');
|
||
});
|
||
});
|
||
|
||
screen.addEventListener('drop', async (e) => {
|
||
const item = e.dataTransfer.items && e.dataTransfer.items[0];
|
||
if (!item) return;
|
||
|
||
const handle = await item.getAsFileSystemHandle();
|
||
if (!handle || handle.kind !== 'directory') {
|
||
alert('Please drop a folder, not a file.');
|
||
return;
|
||
}
|
||
|
||
await openDirectory(handle);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle directory selection via button click
|
||
*/
|
||
async function handleSelectDirectory() {
|
||
try {
|
||
const dirHandle = await window.showDirectoryPicker();
|
||
await openDirectory(dirHandle);
|
||
} catch (err) {
|
||
if (err.name !== 'AbortError') {
|
||
console.error('Error selecting directory:', err);
|
||
alert('Error selecting directory: ' + err.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Open a directory handle and initialize the application
|
||
*/
|
||
async function openDirectory(dirHandle) {
|
||
app.rootHandle = dirHandle;
|
||
|
||
// Hide welcome screen and show main UI
|
||
hideWelcomeScreen();
|
||
showMainUI();
|
||
|
||
// Initialize modules BEFORE scanning (so they're ready for store updates)
|
||
app.modules.spreadsheet.init(); // Subscribe to store
|
||
app.modules.selection.init();
|
||
app.modules.preview.init(); // After selection so it can listen for rowfocused
|
||
app.modules.resize.init();
|
||
app.modules.filter.init();
|
||
app.modules.sort.init();
|
||
app.modules.tree.setupKeyboardShortcuts();
|
||
|
||
// Now scan directory (this will trigger store updates and renders)
|
||
await app.modules.scanner.scanDirectory(dirHandle);
|
||
|
||
// Show refresh button now that a directory is loaded
|
||
if (app.dom.refreshBtn) { app.dom.refreshBtn.classList.remove('hidden'); }
|
||
}
|
||
|
||
/**
|
||
* Handle Refresh button - rescan current directory
|
||
*/
|
||
async function handleRefresh() {
|
||
if (!app.rootHandle) {
|
||
alert('No directory selected');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Clear current data
|
||
app.folderTree = [];
|
||
app.selectedFolders.clear();
|
||
app.lastSelectedFolderPath = null;
|
||
|
||
// Reset store
|
||
app.modules.store.reset();
|
||
|
||
// Rescan directory (modules already initialized, just rescan)
|
||
await app.modules.scanner.scanDirectory(app.rootHandle);
|
||
|
||
} catch (err) {
|
||
console.error('Error refreshing directory:', err);
|
||
alert('Error refreshing directory: ' + err.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle Save All button
|
||
*/
|
||
async function handleSaveAll() {
|
||
if (!confirm('Save all modified files?')) return;
|
||
|
||
try {
|
||
app.dom.saveAllBtn.disabled = true;
|
||
await app.modules.spreadsheet.saveAllFiles();
|
||
} catch (err) {
|
||
console.error('Error saving files:', err);
|
||
alert('Error saving files: ' + err.message);
|
||
} finally {
|
||
app.dom.saveAllBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle Cancel All button
|
||
*/
|
||
function handleCancelAll() {
|
||
if (!confirm('Cancel all changes?')) return;
|
||
app.modules.spreadsheet.cancelAllChanges();
|
||
}
|
||
|
||
/**
|
||
* Handle Export Hashes button
|
||
*/
|
||
function handleExportHashes() {
|
||
app.modules.excel.exportHashes();
|
||
}
|
||
|
||
/**
|
||
* Handle SHA256 checkbox toggle
|
||
*/
|
||
function handleSha256Toggle() {
|
||
app.calculateSha256 = app.dom.sha256Checkbox.checked;
|
||
|
||
// Show/hide SHA256 column
|
||
if (app.calculateSha256) {
|
||
app.dom.sha256Column.classList.remove('hidden');
|
||
} else {
|
||
app.dom.sha256Column.classList.add('hidden');
|
||
}
|
||
|
||
// Re-render table
|
||
app.modules.spreadsheet.render();
|
||
}
|
||
|
||
/**
|
||
* Handle Hide Compliant checkbox toggle
|
||
*/
|
||
function handleHideCompliantToggle() {
|
||
app.hideCompliant = app.dom.hideCompliantCheckbox.checked;
|
||
app.modules.store.setHideCompliant(app.hideCompliant);
|
||
}
|
||
|
||
/**
|
||
* Handle keyboard shortcuts
|
||
*/
|
||
function handleKeyDown(e) {
|
||
// Ctrl+S - Save All
|
||
if (e.ctrlKey && e.key === 's') {
|
||
e.preventDefault();
|
||
if (!app.dom.saveAllBtn.disabled) {
|
||
handleSaveAll();
|
||
}
|
||
}
|
||
|
||
// Escape - Cancel editing
|
||
if (e.key === 'Escape') {
|
||
app.modules.spreadsheet.cancelEditing();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show welcome screen (empty-state overlay)
|
||
*/
|
||
function showWelcomeScreen() {
|
||
if (app.dom.welcomeScreen) {
|
||
app.dom.welcomeScreen.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hide welcome screen (empty-state overlay)
|
||
*/
|
||
function hideWelcomeScreen() {
|
||
if (app.dom.welcomeScreen) {
|
||
app.dom.welcomeScreen.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show main UI (no-op: main UI is always rendered)
|
||
*/
|
||
function showMainUI() {
|
||
// Main app is always visible; only the empty-state overlay is toggled
|
||
}
|
||
|
||
/**
|
||
* Update stats display
|
||
*/
|
||
function updateStats() {
|
||
const files = app.modules.store.getDisplayFiles();
|
||
const totalFiles = files.length;
|
||
const modifiedFiles = files.filter(f => f.isDirty).length;
|
||
const errorFiles = files.filter(f => f.error).length;
|
||
|
||
app.dom.totalFiles.textContent = `${totalFiles} file${totalFiles !== 1 ? 's' : ''}`;
|
||
app.dom.modifiedFiles.textContent = `${modifiedFiles} modified`;
|
||
|
||
if (errorFiles > 0) {
|
||
app.dom.errorFiles.textContent = `${errorFiles} error${errorFiles !== 1 ? 's' : ''}`;
|
||
app.dom.errorFiles.classList.remove('hidden');
|
||
} else {
|
||
app.dom.errorFiles.classList.add('hidden');
|
||
}
|
||
|
||
// Enable/disable bulk action buttons
|
||
app.dom.saveAllBtn.disabled = modifiedFiles === 0;
|
||
app.dom.cancelAllBtn.disabled = modifiedFiles === 0;
|
||
|
||
// Enable/disable export hashes button
|
||
app.dom.exportHashesBtn.disabled = totalFiles === 0 || !app.calculateSha256;
|
||
}
|
||
|
||
// Export functions for use by other modules
|
||
app.modules.app = {
|
||
updateStats
|
||
};
|
||
|
||
// Initialize when DOM is ready
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|
||
|
||
/**
|
||
* Classifier utilities — thin convenience layer over window.zddc.
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
/**
|
||
* Compute new filename from file fields.
|
||
* ZDDC format: trackingNumber_revision (status) - title.ext
|
||
* Falls back to original filename if any required ZDDC field is missing.
|
||
*/
|
||
function computeNewFilename(file) {
|
||
if (file.manualFilename) {
|
||
return file.manualFilename;
|
||
}
|
||
|
||
const formatted = zddc.formatFilename({
|
||
trackingNumber: file.trackingNumber || '',
|
||
revision: file.revision || '',
|
||
status: file.status || '',
|
||
title: file.title || '',
|
||
extension: file.extension || '',
|
||
});
|
||
|
||
return formatted || zddc.joinExtension(file.originalFilename, file.extension);
|
||
}
|
||
|
||
/**
|
||
* Get column value from file object.
|
||
*/
|
||
function getColumnValue(file, columnName) {
|
||
switch (columnName) {
|
||
case 'original': return file.originalFilename || '';
|
||
case 'extension': return file.extension || '';
|
||
case 'new':
|
||
case 'newFilename': return file.manualFilename || computeNewFilename(file);
|
||
case 'trackingNumber': return file.trackingNumber || '';
|
||
case 'revision': return file.revision || '';
|
||
case 'status': return file.status || '';
|
||
case 'title': return file.title || '';
|
||
case 'sha256': return file.sha256 || '';
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get MIME type from file extension (no leading dot).
|
||
*/
|
||
const MIME_TYPES = {
|
||
pdf: 'application/pdf',
|
||
jpg: 'image/jpeg',
|
||
jpeg: 'image/jpeg',
|
||
png: 'image/png',
|
||
gif: 'image/gif',
|
||
svg: 'image/svg+xml',
|
||
webp: 'image/webp',
|
||
bmp: 'image/bmp',
|
||
ico: 'image/x-icon',
|
||
txt: 'text/plain',
|
||
md: 'text/markdown',
|
||
json: 'application/json',
|
||
xml: 'application/xml',
|
||
csv: 'text/csv',
|
||
html: 'text/html',
|
||
css: 'text/css',
|
||
js: 'text/javascript',
|
||
};
|
||
|
||
function getMimeType(extension) {
|
||
return MIME_TYPES[(extension || '').toLowerCase()] || 'application/octet-stream';
|
||
}
|
||
|
||
window.app.modules.utils = {
|
||
computeNewFilename,
|
||
getColumnValue,
|
||
getMimeType,
|
||
};
|
||
})();
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// Escape a string for use in a RegExp (literal match)
|
||
function escapeRegex(str) {
|
||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
// Build regex pattern at parse time based on anchors
|
||
function compilePattern(raw, anchorStart, anchorEnd) {
|
||
var src = (anchorStart ? '^' : '') + raw + (anchorEnd ? '$' : '');
|
||
try {
|
||
return new RegExp(src, 'i');
|
||
} catch (e) {
|
||
// Invalid regex — escape and retry (always succeeds)
|
||
var safe = (anchorStart ? '^' : '') + escapeRegex(raw) + (anchorEnd ? '$' : '');
|
||
return new RegExp(safe, 'i');
|
||
}
|
||
}
|
||
|
||
// Parse a single token string into a node
|
||
function parseToken(token) {
|
||
var s = token;
|
||
var negate = false;
|
||
var anchorStart = false;
|
||
var anchorEnd = false;
|
||
|
||
if (s.charAt(0) === '!') {
|
||
negate = true;
|
||
s = s.slice(1);
|
||
}
|
||
if (s.charAt(0) === '^') {
|
||
anchorStart = true;
|
||
s = s.slice(1);
|
||
}
|
||
if (s.length > 0 && s.charAt(s.length - 1) === '$') {
|
||
anchorEnd = true;
|
||
s = s.slice(0, -1);
|
||
}
|
||
|
||
if (s === '') return null;
|
||
|
||
// bare * (possibly after stripping !) → wildcard-all or wildcard-none
|
||
if (s === '*' && !anchorStart && !anchorEnd) {
|
||
return negate ? null : { type: 'wildcard-all' };
|
||
}
|
||
|
||
var re = compilePattern(s, anchorStart, anchorEnd);
|
||
return { type: negate ? 'no-match' : 'match', re: re };
|
||
}
|
||
|
||
// Parse expression string into AST array
|
||
function parse(expression) {
|
||
if (!expression || typeof expression !== 'string') return [];
|
||
var trimmed = expression.trim();
|
||
if (trimmed === '') return [];
|
||
if (trimmed === '*') return [{ type: 'wildcard-all' }];
|
||
|
||
var ast = [];
|
||
var i = 0;
|
||
var len = trimmed.length;
|
||
|
||
while (i < len) {
|
||
var ch = trimmed.charAt(i);
|
||
|
||
if (ch === '(') {
|
||
var depth = 1;
|
||
var j = i + 1;
|
||
while (j < len && depth > 0) {
|
||
if (trimmed.charAt(j) === '(') depth++;
|
||
else if (trimmed.charAt(j) === ')') depth--;
|
||
j++;
|
||
}
|
||
var innerAst = parse(trimmed.slice(i + 1, j - 1));
|
||
if (innerAst.length === 1) {
|
||
ast.push(innerAst[0]);
|
||
} else if (innerAst.length > 1) {
|
||
for (var k = 0; k < innerAst.length; k++) ast.push(innerAst[k]);
|
||
}
|
||
i = j;
|
||
} else if (ch === '|') {
|
||
ast.push({ type: 'pipe' });
|
||
i++;
|
||
} else if (ch === ' ') {
|
||
i++;
|
||
} else {
|
||
var j = i;
|
||
while (j < len) {
|
||
var c = trimmed.charAt(j);
|
||
if (c === ' ' || c === '(' || c === '|' || c === ')') break;
|
||
j++;
|
||
}
|
||
var token = trimmed.slice(i, j);
|
||
if (token.length > 0) {
|
||
var node = parseToken(token);
|
||
if (node !== null) ast.push(node);
|
||
}
|
||
i = j;
|
||
}
|
||
}
|
||
|
||
// Group pipes into OR nodes
|
||
var hasPipe = false;
|
||
var branches = [[]];
|
||
for (var l = 0; l < ast.length; l++) {
|
||
if (ast[l].type === 'pipe') {
|
||
hasPipe = true;
|
||
branches.push([]);
|
||
} else {
|
||
branches[branches.length - 1].push(ast[l]);
|
||
}
|
||
}
|
||
branches = branches.filter(function(b) { return b.length > 0; });
|
||
|
||
if (!hasPipe) {
|
||
return ast.filter(function(n) { return n.type !== 'pipe'; });
|
||
}
|
||
|
||
var orNodes = branches.map(function(branch) {
|
||
if (branch.length === 1) return branch[0];
|
||
return { type: 'and', nodes: branch };
|
||
});
|
||
return [{ type: 'or', nodes: orNodes }];
|
||
}
|
||
|
||
// Check if a single node matches the value
|
||
function nodeMatches(node, value) {
|
||
switch (node.type) {
|
||
case 'wildcard-all': return true;
|
||
case 'match': return node.re.test(value);
|
||
case 'no-match': return !node.re.test(value);
|
||
case 'or':
|
||
for (var i = 0; i < node.nodes.length; i++) {
|
||
if (nodeMatches(node.nodes[i], value)) return true;
|
||
}
|
||
return false;
|
||
case 'and':
|
||
for (var i = 0; i < node.nodes.length; i++) {
|
||
if (!nodeMatches(node.nodes[i], value)) return false;
|
||
}
|
||
return true;
|
||
default: return false;
|
||
}
|
||
}
|
||
|
||
// Evaluate AST against value
|
||
function matches(value, ast) {
|
||
if (!ast || ast.length === 0) return true;
|
||
var v = String(value); // no forced lowercase — regex has 'i' flag
|
||
for (var i = 0; i < ast.length; i++) {
|
||
if (!nodeMatches(ast[i], v)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
if (!window.zddc) {
|
||
throw new Error('shared/zddc-filter.js: window.zddc must be loaded first');
|
||
}
|
||
window.zddc.filter = { parse: parse, matches: matches };
|
||
})();
|
||
|
||
/**
|
||
* Store Module
|
||
* Single source of truth for all application state
|
||
* Manages files, folders, sorting, filtering
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
// State
|
||
const state = {
|
||
// Directory structure
|
||
rootHandle: null,
|
||
folderTree: [],
|
||
selectedFolders: new Set(),
|
||
|
||
// Files
|
||
allFiles: [], // All files from selected folders
|
||
displayFiles: [], // After sorting and filtering
|
||
|
||
// Sort state
|
||
sortColumns: [], // [{column: 'original', direction: 'asc'}]
|
||
|
||
// Filter state
|
||
filters: {}, // {columnName: AST (from zddc.filter.parse)}
|
||
|
||
// UI state
|
||
hideCompliant: false,
|
||
calculateSha256: false
|
||
};
|
||
|
||
// Listeners for state changes
|
||
const listeners = {
|
||
'files': [],
|
||
'folders': [],
|
||
'sort': [],
|
||
'filter': []
|
||
};
|
||
|
||
/**
|
||
* Set sort columns
|
||
*/
|
||
function on(event, callback) {
|
||
if (listeners[event]) {
|
||
listeners[event].push(callback);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Notify listeners of state change
|
||
*/
|
||
function notify(event) {
|
||
if (listeners[event]) {
|
||
listeners[event].forEach(cb => cb());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set root directory handle
|
||
*/
|
||
function setRootHandle(handle) {
|
||
state.rootHandle = handle;
|
||
}
|
||
|
||
/**
|
||
* Set folder tree
|
||
*/
|
||
function setFolderTree(tree) {
|
||
state.folderTree = tree;
|
||
notify('folders');
|
||
}
|
||
|
||
/**
|
||
* Select/deselect folder
|
||
*/
|
||
function toggleFolder(folderPath) {
|
||
if (state.selectedFolders.has(folderPath)) {
|
||
state.selectedFolders.delete(folderPath);
|
||
} else {
|
||
state.selectedFolders.add(folderPath);
|
||
}
|
||
loadFilesFromSelectedFolders();
|
||
}
|
||
|
||
/**
|
||
* Select multiple folders
|
||
*/
|
||
function setSelectedFolders(folderPaths) {
|
||
state.selectedFolders.clear();
|
||
folderPaths.forEach(path => state.selectedFolders.add(path));
|
||
loadFilesFromSelectedFolders();
|
||
}
|
||
|
||
/**
|
||
* Load files from selected folders
|
||
*/
|
||
function loadFilesFromSelectedFolders() {
|
||
state.allFiles = [];
|
||
|
||
if (state.selectedFolders.size === 0) {
|
||
updateDisplayFiles();
|
||
return;
|
||
}
|
||
|
||
// Collect files from selected folders
|
||
for (const folderPath of state.selectedFolders) {
|
||
const folder = findFolderByPath(folderPath);
|
||
if (folder && folder.files) {
|
||
const files = folder.files.filter(f => !f.isDirectory);
|
||
state.allFiles.push(...files);
|
||
}
|
||
}
|
||
|
||
// Apply default sort if no sort set
|
||
if (state.sortColumns.length === 0) {
|
||
state.sortColumns = [{ column: 'original', direction: 'asc' }];
|
||
}
|
||
|
||
updateDisplayFiles();
|
||
}
|
||
|
||
/**
|
||
* Find folder by path in tree
|
||
*/
|
||
function findFolderByPath(path) {
|
||
function search(folders) {
|
||
for (const folder of folders) {
|
||
if (folder.path === path) return folder;
|
||
if (folder.children) {
|
||
const found = search(folder.children);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
return search(state.folderTree);
|
||
}
|
||
|
||
/**
|
||
* Update display files (apply sort, filter, hide compliant)
|
||
*/
|
||
function updateDisplayFiles() {
|
||
let files = [...state.allFiles];
|
||
|
||
// Apply filters
|
||
files = applyFilters(files);
|
||
|
||
// Apply hide compliant
|
||
if (state.hideCompliant) {
|
||
files = files.filter(file => {
|
||
const newFilename = computeNewFilename(file);
|
||
const validation = validateFilename(newFilename);
|
||
return !validation.isValid;
|
||
});
|
||
}
|
||
|
||
// Apply sort
|
||
files = applySort(files);
|
||
|
||
state.displayFiles = files;
|
||
notify('files');
|
||
}
|
||
|
||
/**
|
||
* Apply filters to files using zddc.filter ASTs
|
||
*/
|
||
function applyFilters(files) {
|
||
if (Object.keys(state.filters).length === 0) {
|
||
return files;
|
||
}
|
||
|
||
return files.filter(file => {
|
||
for (const [columnName, ast] of Object.entries(state.filters)) {
|
||
const value = getColumnValue(file, columnName);
|
||
if (!window.zddc.filter.matches(value, ast)) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Apply sort to files
|
||
*/
|
||
function applySort(files) {
|
||
if (state.sortColumns.length === 0) {
|
||
return files;
|
||
}
|
||
|
||
return files.sort((a, b) => {
|
||
for (const sort of state.sortColumns) {
|
||
const result = compareValues(a, b, sort.column, sort.direction);
|
||
if (result !== 0) return result;
|
||
}
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Compare two values for sorting
|
||
*/
|
||
function compareValues(a, b, columnName, direction) {
|
||
let aVal = getColumnValue(a, columnName);
|
||
let bVal = getColumnValue(b, columnName);
|
||
|
||
const comparison = String(aVal).localeCompare(String(bVal), undefined, {
|
||
numeric: true,
|
||
sensitivity: 'base'
|
||
});
|
||
|
||
return direction === 'asc' ? comparison : -comparison;
|
||
}
|
||
|
||
/**
|
||
* Get column value from file (delegates to utils)
|
||
*/
|
||
function getColumnValue(file, columnName) {
|
||
return window.app.modules.utils.getColumnValue(file, columnName);
|
||
}
|
||
|
||
function computeNewFilename(file) {
|
||
return window.app.modules.utils.computeNewFilename(file);
|
||
}
|
||
|
||
/**
|
||
* Validate filename
|
||
*/
|
||
function validateFilename(filename) {
|
||
// Use existing validator module
|
||
if (window.app.modules.validator) {
|
||
return window.app.modules.validator.validateFilename(filename);
|
||
}
|
||
return { isValid: true, errors: [], warnings: [] };
|
||
}
|
||
|
||
/**
|
||
* Match filter text against value
|
||
*/
|
||
function matchesFilter(value, filterText) {
|
||
// Simple contains for now - can enhance later
|
||
return String(value).toLowerCase().includes(filterText.toLowerCase());
|
||
}
|
||
|
||
/**
|
||
* Set sort columns
|
||
*/
|
||
function setSortColumns(columns) {
|
||
state.sortColumns = columns;
|
||
updateDisplayFiles();
|
||
}
|
||
|
||
/**
|
||
* Toggle sort on column
|
||
*/
|
||
function toggleSort(columnName, multiSort) {
|
||
if (!multiSort) {
|
||
state.sortColumns = [];
|
||
}
|
||
|
||
const existingIndex = state.sortColumns.findIndex(s => s.column === columnName);
|
||
|
||
if (existingIndex >= 0) {
|
||
const current = state.sortColumns[existingIndex];
|
||
if (current.direction === 'asc') {
|
||
current.direction = 'desc';
|
||
} else {
|
||
state.sortColumns.splice(existingIndex, 1);
|
||
}
|
||
} else {
|
||
state.sortColumns.push({ column: columnName, direction: 'asc' });
|
||
}
|
||
|
||
updateDisplayFiles();
|
||
notify('sort');
|
||
}
|
||
|
||
/**
|
||
* Set filter for column. ast is the pre-parsed zddc.filter AST.
|
||
*/
|
||
function setFilter(columnName, filterText, ast) {
|
||
if (filterText && ast && ast.length > 0) {
|
||
state.filters[columnName] = ast;
|
||
} else {
|
||
delete state.filters[columnName];
|
||
}
|
||
updateDisplayFiles();
|
||
}
|
||
|
||
/**
|
||
* Replace all filters at once. filtersObj is {columnName: rawString}.
|
||
* Parses each value. Pass {} to clear all filters.
|
||
*/
|
||
function setAllFilters(filtersObj) {
|
||
state.filters = {};
|
||
for (const [columnName, raw] of Object.entries(filtersObj)) {
|
||
if (raw) {
|
||
const ast = window.zddc.filter.parse(raw);
|
||
if (ast && ast.length > 0) {
|
||
state.filters[columnName] = ast;
|
||
}
|
||
}
|
||
}
|
||
updateDisplayFiles();
|
||
}
|
||
|
||
/**
|
||
* Set hide compliant flag
|
||
*/
|
||
function setHideCompliant(hide) {
|
||
state.hideCompliant = hide;
|
||
updateDisplayFiles();
|
||
}
|
||
|
||
/**
|
||
* Update file data
|
||
*/
|
||
function updateFile(index, updates) {
|
||
const file = state.displayFiles[index];
|
||
if (!file) return;
|
||
|
||
// Apply updates
|
||
Object.assign(file, updates);
|
||
|
||
// Mark as dirty unless explicitly set to false
|
||
if (updates.isDirty !== false) {
|
||
file.isDirty = true;
|
||
}
|
||
|
||
// Notify listeners (will trigger re-render)
|
||
notify('files');
|
||
}
|
||
|
||
/**
|
||
* Update file field (for editing)
|
||
*/
|
||
function updateFileField(index, fieldName, value) {
|
||
const file = state.displayFiles[index];
|
||
if (!file) return;
|
||
|
||
file[fieldName] = value;
|
||
file.autoPopulated = false; // Clear auto-populated flag
|
||
|
||
// Re-evaluate dirty: if every field still matches the parsed original,
|
||
// and there is no manual filename override, the file is clean again.
|
||
file.isDirty = _isFileDirty(file);
|
||
|
||
// Notify listeners
|
||
notify('files');
|
||
}
|
||
|
||
/**
|
||
* A file is dirty if its computed filename differs from the original,
|
||
* or if it has a manual filename override.
|
||
*/
|
||
function _isFileDirty(file) {
|
||
if (file.manualFilename) return true;
|
||
const computed = zddc.formatFilename({
|
||
trackingNumber: file.trackingNumber || '',
|
||
revision: file.revision || '',
|
||
status: file.status || '',
|
||
title: file.title || '',
|
||
extension: file.extension || '',
|
||
});
|
||
const original = zddc.joinExtension(file.originalFilename, file.extension);
|
||
// If formatFilename returns '' (missing fields) fall back to original — not dirty
|
||
return computed !== '' && computed !== original;
|
||
}
|
||
|
||
/**
|
||
* Get display files (what should be shown in table)
|
||
*/
|
||
function getDisplayFiles() {
|
||
return state.displayFiles;
|
||
}
|
||
|
||
/**
|
||
* Get all files (unfiltered)
|
||
*/
|
||
function getAllFiles() {
|
||
return state.allFiles;
|
||
}
|
||
|
||
/**
|
||
* Get sort columns
|
||
*/
|
||
function getSortColumns() {
|
||
return state.sortColumns;
|
||
}
|
||
|
||
/**
|
||
* Get selected folder count
|
||
*/
|
||
function getSelectedFolderCount() {
|
||
return state.selectedFolders.size;
|
||
}
|
||
|
||
/**
|
||
* Get state (read-only)
|
||
*/
|
||
function getState() {
|
||
return {
|
||
rootHandle: state.rootHandle,
|
||
folderTree: state.folderTree,
|
||
selectedFolders: Array.from(state.selectedFolders),
|
||
allFiles: state.allFiles,
|
||
displayFiles: state.displayFiles,
|
||
sortColumns: state.sortColumns,
|
||
filters: state.filters,
|
||
hideCompliant: state.hideCompliant
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Reset all state
|
||
*/
|
||
function reset() {
|
||
state.rootHandle = null;
|
||
state.folderTree = [];
|
||
state.selectedFolders.clear();
|
||
state.allFiles = [];
|
||
state.displayFiles = [];
|
||
state.sortColumns = [];
|
||
state.filters = {};
|
||
state.hideCompliant = false;
|
||
|
||
notify('files');
|
||
notify('folders');
|
||
}
|
||
|
||
// Export
|
||
window.app.modules.store = {
|
||
on,
|
||
notify,
|
||
setRootHandle,
|
||
setFolderTree,
|
||
toggleFolder,
|
||
setSelectedFolders,
|
||
toggleSort,
|
||
setFilter,
|
||
setAllFilters,
|
||
setHideCompliant,
|
||
updateFile,
|
||
updateFileField,
|
||
getDisplayFiles,
|
||
getAllFiles,
|
||
getSortColumns,
|
||
getSelectedFolderCount,
|
||
getState,
|
||
reset
|
||
};
|
||
})();
|
||
|
||
/**
|
||
* ZDDC Validation Module
|
||
* Validates file names against ZDDC conventions using the shared zddc library.
|
||
*/
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
/**
|
||
* Validate a filename and return a detailed result.
|
||
* Delegates ZDDC pattern checking to the shared zddc.parseFilename() library.
|
||
*/
|
||
function validateFilename(filename) {
|
||
const errors = [];
|
||
const warnings = [];
|
||
|
||
if (!filename) {
|
||
errors.push('Filename is empty.');
|
||
return { isValid: false, warnings, errors };
|
||
}
|
||
|
||
const parsed = zddc.parseFilename(filename);
|
||
if (!parsed || !parsed.valid) {
|
||
errors.push('Filename does not match ZDDC format: trackingNumber_revision (status) - title.ext');
|
||
} else if (!zddc.isValidStatus(parsed.status)) {
|
||
errors.push('Invalid status code "' + parsed.status + '". Valid codes: ' + zddc.STATUSES.join(', '));
|
||
}
|
||
|
||
if (filename.length > 255) {
|
||
warnings.push('Filename is very long (>255 characters)');
|
||
}
|
||
|
||
const invalidChars = /[<>:"|?*]/;
|
||
if (invalidChars.test(filename)) {
|
||
errors.push('Filename contains invalid characters: < > : " | ? *');
|
||
}
|
||
|
||
return {
|
||
isValid: errors.length === 0,
|
||
warnings,
|
||
errors
|
||
};
|
||
}
|
||
|
||
window.app.modules.validator = {
|
||
validateFilename
|
||
};
|
||
})();
|
||
|
||
/**
|
||
* Directory Scanner Module
|
||
* Scans directories and collects files
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
// Store ZIP data for later access
|
||
const zipCache = new Map(); // path -> { zip: JSZip, fileHandle: FileSystemFileHandle }
|
||
|
||
/**
|
||
* Scan directory and build folder tree with files
|
||
*/
|
||
async function scanDirectory(dirHandle, preserveState = false) {
|
||
|
||
|
||
// Save current state if preserving
|
||
let savedExpanded = new Set();
|
||
let savedSelected = new Set();
|
||
if (preserveState) {
|
||
savedExpanded = getExpandedPaths(window.app.folderTree);
|
||
savedSelected = new Set(window.app.selectedFolders);
|
||
}
|
||
|
||
// Clear ZIP cache
|
||
zipCache.clear();
|
||
|
||
// Map to store files by folder handle (or ZIP path for virtual folders)
|
||
const foldersMap = new Map();
|
||
|
||
// Recursively scan
|
||
await scanFolder(dirHandle, foldersMap, dirHandle.name);
|
||
|
||
// Build tree structure
|
||
window.app.folderTree = window.app.modules.tree.buildTree(dirHandle, foldersMap);
|
||
|
||
// Set in store
|
||
window.app.modules.store.setFolderTree(window.app.folderTree);
|
||
|
||
if (preserveState) {
|
||
// Restore expanded state
|
||
restoreExpandedPaths(window.app.folderTree, savedExpanded);
|
||
// Restore selection
|
||
window.app.selectedFolders = savedSelected;
|
||
// Render without changing selection
|
||
window.app.modules.tree.render();
|
||
window.app.modules.store.setSelectedFolders(savedSelected);
|
||
} else {
|
||
// Render tree
|
||
window.app.modules.tree.render();
|
||
// Auto-expand and select all folders
|
||
window.app.modules.tree.expandAll();
|
||
window.app.modules.tree.selectAll();
|
||
}
|
||
|
||
|
||
}
|
||
|
||
/**
|
||
* Get all expanded folder paths from tree
|
||
*/
|
||
function getExpandedPaths(folders, paths = new Set()) {
|
||
for (const folder of folders) {
|
||
if (folder.expanded) {
|
||
paths.add(folder.path);
|
||
}
|
||
if (folder.children) {
|
||
getExpandedPaths(folder.children, paths);
|
||
}
|
||
}
|
||
return paths;
|
||
}
|
||
|
||
/**
|
||
* Restore expanded state to tree
|
||
*/
|
||
function restoreExpandedPaths(folders, expandedPaths) {
|
||
for (const folder of folders) {
|
||
folder.expanded = expandedPaths.has(folder.path);
|
||
if (folder.children) {
|
||
restoreExpandedPaths(folder.children, expandedPaths);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Recursively scan a folder
|
||
*/
|
||
async function scanFolder(dirHandle, foldersMap, currentPath) {
|
||
const items = [];
|
||
|
||
try {
|
||
for await (const entry of dirHandle.values()) {
|
||
if (entry.kind === 'file') {
|
||
// Create file object
|
||
const file = await createFileObject(entry, dirHandle);
|
||
if (file) {
|
||
items.push(file);
|
||
|
||
// Check if it's a ZIP file - scan its contents
|
||
if (file.extension === 'zip' && typeof JSZip !== 'undefined') {
|
||
await scanZipFile(file, foldersMap, currentPath, items);
|
||
}
|
||
}
|
||
} else if (entry.kind === 'directory') {
|
||
// Add directory reference
|
||
items.push({
|
||
handle: entry,
|
||
isDirectory: true
|
||
});
|
||
|
||
// Recursively scan subdirectory
|
||
const childPath = currentPath + '/' + entry.name;
|
||
await scanFolder(entry, foldersMap, childPath);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Error scanning folder:', dirHandle.name, err);
|
||
}
|
||
|
||
// Store files for this folder
|
||
foldersMap.set(dirHandle, items);
|
||
}
|
||
|
||
/**
|
||
* Scan a ZIP file and add its contents as virtual folders
|
||
*/
|
||
async function scanZipFile(zipFileObj, foldersMap, parentPath, parentItems) {
|
||
try {
|
||
const fileObj = await zipFileObj.handle.getFile();
|
||
const arrayBuffer = await fileObj.arrayBuffer();
|
||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||
|
||
const zipPath = parentPath + '/' + zddc.joinExtension(zipFileObj.originalFilename, zipFileObj.extension);
|
||
|
||
// Cache the ZIP for later extraction
|
||
zipCache.set(zipPath, {
|
||
zip: zip,
|
||
fileHandle: zipFileObj.handle,
|
||
folderHandle: zipFileObj.folderHandle
|
||
});
|
||
|
||
// Mark the file as a ZIP container
|
||
zipFileObj.isZipContainer = true;
|
||
zipFileObj.zipPath = zipPath;
|
||
|
||
// Build virtual folder structure from ZIP contents
|
||
const virtualFolders = new Map(); // path -> { files: [], subdirs: Set }
|
||
virtualFolders.set(zipPath, { files: [], subdirs: new Set() });
|
||
|
||
zip.forEach((relativePath, zipEntry) => {
|
||
if (zipEntry.dir) {
|
||
// It's a directory
|
||
const dirPath = zipPath + '/' + relativePath.replace(/\/$/, '');
|
||
if (!virtualFolders.has(dirPath)) {
|
||
virtualFolders.set(dirPath, { files: [], subdirs: new Set() });
|
||
}
|
||
// Add to parent's subdirs
|
||
const parentDir = dirPath.substring(0, dirPath.lastIndexOf('/'));
|
||
if (virtualFolders.has(parentDir)) {
|
||
virtualFolders.get(parentDir).subdirs.add(dirPath);
|
||
}
|
||
} else {
|
||
// It's a file
|
||
const fileName = relativePath.split('/').pop();
|
||
const fileDir = relativePath.includes('/')
|
||
? zipPath + '/' + relativePath.substring(0, relativePath.lastIndexOf('/'))
|
||
: zipPath;
|
||
|
||
// Ensure parent directories exist
|
||
ensureVirtualPath(virtualFolders, zipPath, fileDir);
|
||
|
||
// Create virtual file object
|
||
const split = zddc.splitExtension(fileName);
|
||
|
||
const virtualFile = {
|
||
originalFilename: split.name,
|
||
extension: split.extension,
|
||
size: zipEntry._data ? zipEntry._data.uncompressedSize : 0,
|
||
lastModified: zipEntry.date ? zipEntry.date.getTime() : Date.now(),
|
||
|
||
// Virtual file markers
|
||
isVirtual: true,
|
||
zipPath: zipPath,
|
||
zipEntryPath: relativePath,
|
||
|
||
// Editable fields
|
||
trackingNumber: '',
|
||
revision: '',
|
||
status: '',
|
||
title: '',
|
||
|
||
// State
|
||
isDirty: false,
|
||
error: false,
|
||
errorMessage: '',
|
||
validation: null,
|
||
sha256: null
|
||
};
|
||
|
||
virtualFolders.get(fileDir).files.push(virtualFile);
|
||
}
|
||
});
|
||
|
||
// Convert virtual folders to format compatible with tree builder
|
||
// Create a virtual handle for the ZIP root
|
||
const zipVirtualHandle = {
|
||
name: zddc.joinExtension(zipFileObj.originalFilename, zipFileObj.extension),
|
||
kind: 'directory',
|
||
isZipRoot: true,
|
||
zipPath: zipPath
|
||
};
|
||
|
||
// Store virtual folder data
|
||
buildVirtualFolderMap(virtualFolders, zipPath, foldersMap, zipVirtualHandle);
|
||
|
||
// Add ZIP as a virtual directory in parent
|
||
parentItems.push({
|
||
handle: zipVirtualHandle,
|
||
isDirectory: true,
|
||
isZipRoot: true
|
||
});
|
||
|
||
} catch (err) {
|
||
console.error('Error scanning ZIP file:', zipFileObj.originalFilename, err);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ensure all parent directories exist in virtual folder map
|
||
*/
|
||
function ensureVirtualPath(virtualFolders, zipPath, targetPath) {
|
||
if (virtualFolders.has(targetPath)) return;
|
||
|
||
const parts = targetPath.substring(zipPath.length + 1).split('/').filter(p => p);
|
||
let currentPath = zipPath;
|
||
|
||
for (const part of parts) {
|
||
const parentPath = currentPath;
|
||
currentPath = currentPath + '/' + part;
|
||
|
||
if (!virtualFolders.has(currentPath)) {
|
||
virtualFolders.set(currentPath, { files: [], subdirs: new Set() });
|
||
}
|
||
|
||
if (virtualFolders.has(parentPath)) {
|
||
virtualFolders.get(parentPath).subdirs.add(currentPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build virtual folder entries for the foldersMap
|
||
* Uses path strings as keys for virtual folders to avoid object reference issues
|
||
*/
|
||
function buildVirtualFolderMap(virtualFolders, zipPath, foldersMap, zipVirtualHandle) {
|
||
const rootData = virtualFolders.get(zipPath);
|
||
if (!rootData) return;
|
||
|
||
// Create items array for ZIP root
|
||
const rootItems = [...rootData.files];
|
||
|
||
// Add subdirectories
|
||
for (const subdirPath of rootData.subdirs) {
|
||
const subdirName = subdirPath.split('/').pop();
|
||
const subdirHandle = {
|
||
name: subdirName,
|
||
kind: 'directory',
|
||
isVirtualDir: true,
|
||
virtualPath: subdirPath,
|
||
zipPath: zipPath
|
||
};
|
||
rootItems.push({
|
||
handle: subdirHandle,
|
||
isDirectory: true,
|
||
isVirtualDir: true
|
||
});
|
||
|
||
// Recursively add subdir contents
|
||
buildVirtualSubfolder(virtualFolders, subdirPath, foldersMap, zipPath);
|
||
}
|
||
|
||
// Store with both the handle object AND the path string as keys
|
||
// This ensures lookup works regardless of which reference is used
|
||
foldersMap.set(zipVirtualHandle, rootItems);
|
||
foldersMap.set(zipPath, rootItems); // Path-based key for tree building
|
||
}
|
||
|
||
/**
|
||
* Recursively build virtual subfolder entries
|
||
*/
|
||
function buildVirtualSubfolder(virtualFolders, folderPath, foldersMap, zipPath) {
|
||
const folderData = virtualFolders.get(folderPath);
|
||
if (!folderData) return;
|
||
|
||
const folderName = folderPath.split('/').pop();
|
||
const folderHandle = {
|
||
name: folderName,
|
||
kind: 'directory',
|
||
isVirtualDir: true,
|
||
virtualPath: folderPath,
|
||
zipPath: zipPath
|
||
};
|
||
|
||
const items = [...folderData.files];
|
||
|
||
// Store with path string key for tree building lookup
|
||
foldersMap.set(folderPath, items);
|
||
|
||
// Add subdirectories
|
||
for (const subdirPath of folderData.subdirs) {
|
||
const subdirName = subdirPath.split('/').pop();
|
||
const subdirHandle = {
|
||
name: subdirName,
|
||
kind: 'directory',
|
||
isVirtualDir: true,
|
||
virtualPath: subdirPath,
|
||
zipPath: zipPath
|
||
};
|
||
items.push({
|
||
handle: subdirHandle,
|
||
isDirectory: true,
|
||
isVirtualDir: true
|
||
});
|
||
|
||
// Recursively add subdir contents
|
||
buildVirtualSubfolder(virtualFolders, subdirPath, foldersMap, zipPath);
|
||
}
|
||
|
||
foldersMap.set(folderHandle, items);
|
||
}
|
||
|
||
/**
|
||
* Get cached ZIP data
|
||
*/
|
||
function getZipCache(zipPath) {
|
||
return zipCache.get(zipPath);
|
||
}
|
||
|
||
/**
|
||
* Extract a ZIP file to its parent directory
|
||
*/
|
||
async function extractZip(zipPath) {
|
||
const cached = zipCache.get(zipPath);
|
||
if (!cached) {
|
||
throw new Error('ZIP not found in cache');
|
||
}
|
||
|
||
const { zip, folderHandle } = cached;
|
||
|
||
// Get the ZIP filename without extension for the extract folder name
|
||
const zipName = zipPath.split('/').pop();
|
||
const extractFolderName = zipName.replace(/\.zip$/i, '');
|
||
|
||
// Create extraction folder
|
||
const extractFolder = await folderHandle.getDirectoryHandle(extractFolderName, { create: true });
|
||
|
||
// Extract all files
|
||
const entries = [];
|
||
zip.forEach((relativePath, zipEntry) => {
|
||
if (!zipEntry.dir) {
|
||
entries.push({ path: relativePath, entry: zipEntry });
|
||
}
|
||
});
|
||
|
||
for (const { path, entry } of entries) {
|
||
try {
|
||
// Create subdirectories if needed
|
||
const parts = path.split('/');
|
||
const fileName = parts.pop();
|
||
|
||
let currentDir = extractFolder;
|
||
for (const part of parts) {
|
||
if (part) {
|
||
currentDir = await currentDir.getDirectoryHandle(part, { create: true });
|
||
}
|
||
}
|
||
|
||
// Write file
|
||
const content = await entry.async('arraybuffer');
|
||
const fileHandle = await currentDir.getFileHandle(fileName, { create: true });
|
||
const writable = await fileHandle.createWritable();
|
||
await writable.write(content);
|
||
await writable.close();
|
||
} catch (err) {
|
||
console.error('Error extracting file:', path, err);
|
||
}
|
||
}
|
||
|
||
return extractFolderName;
|
||
}
|
||
|
||
/**
|
||
* Create file object with metadata
|
||
*/
|
||
async function createFileObject(fileHandle, folderHandle) {
|
||
try {
|
||
const file = await fileHandle.getFile();
|
||
const split = zddc.splitExtension(file.name);
|
||
|
||
return {
|
||
handle: fileHandle,
|
||
folderHandle: folderHandle,
|
||
originalFilename: split.name,
|
||
extension: split.extension,
|
||
size: file.size,
|
||
lastModified: file.lastModified,
|
||
|
||
// Editable fields
|
||
trackingNumber: '',
|
||
revision: '',
|
||
status: '',
|
||
title: '',
|
||
|
||
// State
|
||
isDirty: false,
|
||
error: false,
|
||
errorMessage: '',
|
||
validation: null,
|
||
sha256: null
|
||
// folderPath will be added later in buildTree
|
||
};
|
||
} catch (err) {
|
||
console.error('Error reading file:', fileHandle.name, err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Export module
|
||
window.app.modules.scanner = {
|
||
scanDirectory,
|
||
getZipCache,
|
||
extractZip
|
||
};
|
||
})();
|
||
|
||
|
||
/**
|
||
* Folder Tree Module
|
||
* Handles folder tree rendering and multi-select
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
/**
|
||
* Render the folder tree
|
||
*/
|
||
function render() {
|
||
const container = window.app.dom.folderTree;
|
||
container.innerHTML = '';
|
||
|
||
if (window.app.folderTree.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">No folders found</div>';
|
||
return;
|
||
}
|
||
|
||
window.app.folderTree.forEach(folder => {
|
||
const element = createFolderElement(folder);
|
||
container.appendChild(element);
|
||
});
|
||
|
||
updateSelectedCount();
|
||
}
|
||
|
||
/**
|
||
* Create a folder element
|
||
*/
|
||
function createFolderElement(folder, level = 0) {
|
||
const div = document.createElement('div');
|
||
|
||
const item = document.createElement('div');
|
||
item.className = 'folder-item';
|
||
item.dataset.path = folder.path;
|
||
item.style.paddingLeft = `${level * 1.5}rem`;
|
||
|
||
// Check if selected
|
||
if (window.app.selectedFolders.has(folder.path)) {
|
||
item.classList.add('selected');
|
||
}
|
||
|
||
// Toggle button (if has children)
|
||
const toggle = document.createElement('span');
|
||
toggle.className = 'folder-toggle';
|
||
if (folder.children && folder.children.length > 0) {
|
||
toggle.textContent = folder.expanded ? '▼' : '▶';
|
||
toggle.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const recursive = e.ctrlKey || e.metaKey;
|
||
toggleFolder(folder, recursive);
|
||
});
|
||
} else {
|
||
toggle.textContent = ' ';
|
||
}
|
||
item.appendChild(toggle);
|
||
|
||
// Folder icon (different for ZIP files)
|
||
const icon = document.createElement('span');
|
||
icon.className = 'folder-icon';
|
||
if (folder.isZipRoot) {
|
||
icon.innerHTML = '📦'; // 📦
|
||
} else if (folder.isVirtualDir) {
|
||
icon.innerHTML = '📂'; // 📂
|
||
} else {
|
||
icon.innerHTML = '📁'; // 📁
|
||
}
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
const headerRow = allRows[0];
|
||
const dataRows = allRows.slice(1);
|
||
let html = '<table>';
|
||
html += '<tr>' + headerRow.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>';
|
||
for (const row of dataRows) {
|
||
html += '<tr>' + row.map(c => '<td>' + esc(c) + '</td>').join('') + '</tr>';
|
||
}
|
||
html += '</table>';
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* Handle copy event
|
||
*/
|
||
function handleCopy(e) {
|
||
if (!selectionStart || !selectionEnd) return;
|
||
|
||
const minCol = Math.min(selectionStart.col, selectionEnd.col);
|
||
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
|
||
|
||
// Get column headers for selected range
|
||
const headers = getColumnHeaders(minCol, maxCol);
|
||
|
||
const data = getSelectionData();
|
||
if (!data) return;
|
||
|
||
// Prepend header row
|
||
const allRows = [headers, ...data];
|
||
|
||
// Convert to TSV and HTML table
|
||
const tsv = allRows.map(row => row.join('\t')).join('\n');
|
||
|
||
e.clipboardData.setData('text/plain', tsv);
|
||
e.clipboardData.setData('text/html', rowsToHtml(allRows));
|
||
e.preventDefault();
|
||
}
|
||
|
||
/**
|
||
* Handle cut event
|
||
*/
|
||
function handleCut(e) {
|
||
if (!selectionStart || !selectionEnd) return;
|
||
|
||
const minCol = Math.min(selectionStart.col, selectionEnd.col);
|
||
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
|
||
|
||
// Get column headers for selected range
|
||
const headers = getColumnHeaders(minCol, maxCol);
|
||
|
||
const data = getSelectionData();
|
||
if (!data) return;
|
||
|
||
// Prepend header row
|
||
const allRows = [headers, ...data];
|
||
|
||
// Convert to TSV
|
||
const tsv = allRows.map(row => row.join('\t')).join('\n');
|
||
|
||
e.clipboardData.setData('text/plain', tsv);
|
||
e.clipboardData.setData('text/html', rowsToHtml(allRows));
|
||
|
||
// Clear selected cells (only editable ones)
|
||
clearSelectionData();
|
||
|
||
e.preventDefault();
|
||
}
|
||
|
||
/**
|
||
* Handle paste event
|
||
*/
|
||
function handlePaste(e) {
|
||
// Don't intercept paste in input fields
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
|
||
return;
|
||
}
|
||
|
||
if (!selectionStart) return;
|
||
|
||
const tsv = e.clipboardData.getData('text/plain');
|
||
if (!tsv) return;
|
||
|
||
e.preventDefault();
|
||
executePaste(tsv);
|
||
}
|
||
|
||
/**
|
||
* Execute paste from TSV string into the selected range.
|
||
* - If pasted cols > selected cols, right-align (user likely copied all but only pastes back editable cols).
|
||
* - If pasted data includes the first two columns (#, Original), validate they match.
|
||
* - If pasted row count != selected row count, abort with error.
|
||
*/
|
||
function executePaste(tsv) {
|
||
if (!selectionStart || !selectionEnd) return;
|
||
|
||
// Parse TSV
|
||
let rows = tsv.split('\n').map(row => row.split('\t'));
|
||
|
||
// Filter out empty trailing rows
|
||
while (rows.length > 0 && rows[rows.length - 1].every(cell => !cell.trim())) {
|
||
rows.pop();
|
||
}
|
||
if (rows.length === 0) return;
|
||
|
||
// Check if first row is a header row and skip it
|
||
if (rows.length > 1 && isHeaderRow(rows[0])) {
|
||
rows = rows.slice(1);
|
||
}
|
||
if (rows.length === 0) return;
|
||
|
||
// Selection bounds
|
||
const minRow = Math.min(selectionStart.row, selectionEnd.row);
|
||
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
|
||
const minCol = Math.min(selectionStart.col, selectionEnd.col);
|
||
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
|
||
const selectedRowCount = maxRow - minRow + 1;
|
||
const selectedColCount = maxCol - minCol + 1;
|
||
const pastedColCount = rows[0].length;
|
||
|
||
// Row count validation: must match
|
||
if (rows.length !== selectedRowCount) {
|
||
alert(`Paste aborted: row count mismatch.\n` +
|
||
`Selected ${selectedRowCount} row(s), but clipboard has ${rows.length} row(s).`);
|
||
return;
|
||
}
|
||
|
||
// Determine column offset for right-alignment
|
||
let colOffset = 0;
|
||
if (pastedColCount > selectedColCount) {
|
||
colOffset = pastedColCount - selectedColCount;
|
||
}
|
||
|
||
// Get column names from header
|
||
const headerCells = window.app.dom.spreadsheet.querySelectorAll('thead th');
|
||
const columnNames = Array.from(headerCells).map(th => {
|
||
const match = th.className.match(/col-(\w+)/);
|
||
return match ? match[1] : '';
|
||
});
|
||
|
||
// If pasted data includes first two columns (row-num, original), validate they match
|
||
if (colOffset === 0 && pastedColCount >= selectedColCount) {
|
||
// Check if pasted range starts at col 0 or col 1 (row-num or original)
|
||
const files = window.app.modules.store.getDisplayFiles();
|
||
const startsAtRowNum = (minCol === 0);
|
||
const startsAtOriginal = (minCol === 1);
|
||
|
||
if (startsAtRowNum || startsAtOriginal) {
|
||
for (let r = 0; r < rows.length; r++) {
|
||
const targetRowIndex = minRow + r;
|
||
const file = files[targetRowIndex];
|
||
if (!file) continue;
|
||
|
||
if (startsAtRowNum) {
|
||
// Validate col 0 = row number, col 1 = original filename
|
||
const expectedNum = String(targetRowIndex + 1);
|
||
const pastedNum = rows[r][0]?.trim();
|
||
const pastedOriginal = rows[r][1]?.trim();
|
||
if (pastedNum && pastedNum !== expectedNum) {
|
||
alert(`Paste aborted: row number mismatch at row ${targetRowIndex + 1}.\n` +
|
||
`Expected "${expectedNum}", got "${pastedNum}".\n` +
|
||
`Data may be shuffled.`);
|
||
return;
|
||
}
|
||
if (pastedOriginal && pastedOriginal !== file.originalFilename) {
|
||
alert(`Paste aborted: filename mismatch at row ${targetRowIndex + 1}.\n` +
|
||
`Expected "${file.originalFilename}", got "${pastedOriginal}".\n` +
|
||
`Data may be shuffled.`);
|
||
return;
|
||
}
|
||
} else if (startsAtOriginal) {
|
||
// Validate col 0 of paste = original filename
|
||
const pastedOriginal = rows[r][0]?.trim();
|
||
if (pastedOriginal && pastedOriginal !== file.originalFilename) {
|
||
alert(`Paste aborted: filename mismatch at row ${targetRowIndex + 1}.\n` +
|
||
`Expected "${file.originalFilename}", got "${pastedOriginal}".\n` +
|
||
`Data may be shuffled.`);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Editable columns
|
||
const editableColumns = ['newFilename', 'trackingNumber', 'revision', 'status', 'title'];
|
||
const files = window.app.modules.store.getDisplayFiles();
|
||
let updatedCount = 0;
|
||
|
||
for (let r = 0; r < rows.length; r++) {
|
||
const targetRowIndex = minRow + r;
|
||
if (targetRowIndex >= files.length) continue;
|
||
const file = files[targetRowIndex];
|
||
if (!file) continue;
|
||
|
||
const rowData = rows[r];
|
||
|
||
for (let c = 0; c < selectedColCount; c++) {
|
||
const pasteIdx = c + colOffset; // right-align: skip leading pasted cols
|
||
if (pasteIdx >= rowData.length) continue;
|
||
|
||
const targetColIndex = minCol + c;
|
||
if (targetColIndex >= columnNames.length) continue;
|
||
|
||
const columnName = columnNames[targetColIndex];
|
||
if (!editableColumns.includes(columnName)) continue;
|
||
|
||
const value = rowData[pasteIdx]?.trim() || '';
|
||
|
||
if (columnName === 'newFilename') {
|
||
if (value) {
|
||
file.manualFilename = value;
|
||
} else {
|
||
delete file.manualFilename;
|
||
}
|
||
} else {
|
||
file[columnName] = value;
|
||
if (file.manualFilename) {
|
||
delete file.manualFilename;
|
||
}
|
||
}
|
||
|
||
file.isDirty = true;
|
||
file.autoPopulated = false;
|
||
updatedCount++;
|
||
}
|
||
}
|
||
|
||
// Re-render and restore selection
|
||
if (updatedCount > 0) {
|
||
window.app.modules.spreadsheet.render();
|
||
// Restore selection highlight
|
||
updateSelection();
|
||
showToast(`Pasted ${updatedCount} cell(s)`, 'success');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show a brief toast notification
|
||
*/
|
||
function showToast(message, type) {
|
||
if (window.app.modules.excel && window.app.modules.excel.showToast) {
|
||
window.app.modules.excel.showToast(message, type);
|
||
return;
|
||
}
|
||
// Fallback: simple toast
|
||
const toast = document.createElement('div');
|
||
toast.textContent = message;
|
||
toast.style.cssText = 'position:fixed;bottom:20px;right:20px;padding:8px 16px;' +
|
||
'background:' + (type === 'success' ? '#28a745' : '#dc3545') + ';color:#fff;' +
|
||
'border-radius:4px;z-index:9999;font-size:14px;';
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.remove(), 3000);
|
||
}
|
||
|
||
/**
|
||
* Get data from selected cells
|
||
*/
|
||
function getSelectionData() {
|
||
if (!selectionStart || !selectionEnd) return null;
|
||
|
||
const minRow = Math.min(selectionStart.row, selectionEnd.row);
|
||
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
|
||
const minCol = Math.min(selectionStart.col, selectionEnd.col);
|
||
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
|
||
|
||
const data = [];
|
||
const tbody = window.app.dom.spreadsheetBody;
|
||
const rows = tbody.querySelectorAll('tr');
|
||
|
||
for (let r = minRow; r <= maxRow; r++) {
|
||
const row = rows[r];
|
||
if (!row) continue;
|
||
|
||
const rowData = [];
|
||
const cells = row.children;
|
||
|
||
for (let c = minCol; c <= maxCol; c++) {
|
||
const cell = cells[c];
|
||
if (cell) {
|
||
rowData.push(cell.textContent.trim());
|
||
} else {
|
||
rowData.push('');
|
||
}
|
||
}
|
||
|
||
data.push(rowData);
|
||
}
|
||
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Clear data from selected cells (only editable ones)
|
||
*/
|
||
function clearSelectionData() {
|
||
if (!selectionStart || !selectionEnd) return;
|
||
|
||
const minRow = Math.min(selectionStart.row, selectionEnd.row);
|
||
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
|
||
const minCol = Math.min(selectionStart.col, selectionEnd.col);
|
||
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
|
||
|
||
const tbody = window.app.dom.spreadsheetBody;
|
||
const rows = tbody.querySelectorAll('tr');
|
||
|
||
// Get column names from header
|
||
const headerCells = window.app.dom.spreadsheet.querySelectorAll('thead th');
|
||
const columnNames = Array.from(headerCells).map(th => {
|
||
const className = th.className.replace('col-', '');
|
||
return className;
|
||
});
|
||
|
||
for (let r = minRow; r <= maxRow; r++) {
|
||
const row = rows[r];
|
||
if (!row) continue;
|
||
|
||
const rowIndex = parseInt(row.dataset.index);
|
||
const files = window.app.modules.store.getDisplayFiles();
|
||
const file = files[rowIndex];
|
||
if (!file) continue;
|
||
|
||
const cells = row.children;
|
||
|
||
for (let c = minCol; c <= maxCol; c++) {
|
||
const cell = cells[c];
|
||
if (!cell || !cell.classList.contains('cell-editable')) continue;
|
||
|
||
const columnName = columnNames[c];
|
||
|
||
// Clear the data
|
||
if (columnName === 'newFilename') {
|
||
delete file.manualFilename;
|
||
} else {
|
||
file[columnName] = '';
|
||
}
|
||
|
||
file.isDirty = true;
|
||
}
|
||
}
|
||
|
||
// Re-render
|
||
window.app.modules.spreadsheet.render();
|
||
}
|
||
|
||
/**
|
||
* Copy selection to clipboard via button click
|
||
*/
|
||
function doCopy() {
|
||
if (!selectionStart || !selectionEnd) return;
|
||
|
||
const minCol = Math.min(selectionStart.col, selectionEnd.col);
|
||
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
|
||
const headers = getColumnHeaders(minCol, maxCol);
|
||
const data = getSelectionData();
|
||
if (!data) return;
|
||
|
||
const allRows = [headers, ...data];
|
||
const tsv = allRows.map(row => row.join('\t')).join('\n');
|
||
const html = rowsToHtml(allRows);
|
||
|
||
// Write both plain text and HTML to clipboard
|
||
const htmlBlob = new Blob([html], { type: 'text/html' });
|
||
const textBlob = new Blob([tsv], { type: 'text/plain' });
|
||
const item = new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob });
|
||
|
||
navigator.clipboard.write([item]).then(() => {
|
||
showToast(`Copied ${data.length} row(s)`, 'success');
|
||
}).catch(err => {
|
||
// Fallback to plain text if ClipboardItem not supported
|
||
navigator.clipboard.writeText(tsv).then(() => {
|
||
showToast(`Copied ${data.length} row(s)`, 'success');
|
||
}).catch(err2 => {
|
||
console.error('Copy failed:', err2);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Paste from clipboard via button click
|
||
*/
|
||
function doPaste() {
|
||
if (!selectionStart || !selectionEnd) return;
|
||
|
||
navigator.clipboard.readText().then(tsv => {
|
||
if (tsv) executePaste(tsv);
|
||
}).catch(err => {
|
||
console.error('Paste failed:', err);
|
||
alert('Cannot read clipboard. Use Ctrl+V instead, or grant clipboard permission.');
|
||
});
|
||
}
|
||
|
||
// Export module
|
||
window.app.modules.selection = {
|
||
init,
|
||
clearSelection,
|
||
hasSelection,
|
||
doCopy,
|
||
doPaste
|
||
};
|
||
})();
|
||
|
||
/**
|
||
* Preview Module
|
||
* Opens file preview in a separate popup window
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
let currentBlobUrl = null;
|
||
let currentFile = null;
|
||
let currentRowIndex = null;
|
||
let previewWindow = null;
|
||
|
||
// Use shared extension lists from window.zddc.preview where possible
|
||
const IMAGE_EXTENSIONS = zddc.preview.IMAGE_EXTENSIONS;
|
||
const TIFF_EXTENSIONS = zddc.preview.TIFF_EXTENSIONS;
|
||
const TEXT_EXTENSIONS = zddc.preview.TEXT_EXTENSIONS;
|
||
const PDF_EXTENSIONS = ['pdf'];
|
||
const ZIP_EXTENSIONS = ['zip'];
|
||
|
||
// Lazily load a script from CDN — delegates to shared cache.
|
||
const loadLibrary = zddc.preview.loadLibrary;
|
||
|
||
/**
|
||
* Initialize preview module
|
||
*/
|
||
function init() {
|
||
// Listen for row focused events from selection module
|
||
document.addEventListener('rowfocused', handleRowFocused);
|
||
|
||
// Set up toggle button to open/close preview window
|
||
const toggleBtn = document.getElementById('togglePreviewBtn');
|
||
if (toggleBtn) {
|
||
toggleBtn.addEventListener('click', () => {
|
||
if (previewWindow && !previewWindow.closed) {
|
||
// Close preview window
|
||
previewWindow.close();
|
||
previewWindow = null;
|
||
toggleBtn.classList.remove('preview-active');
|
||
} else if (currentFile) {
|
||
openPreviewWindow(currentFile);
|
||
toggleBtn.classList.add('preview-active');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle row focused event
|
||
*/
|
||
function handleRowFocused(e) {
|
||
const { rowIndex, file } = e.detail;
|
||
|
||
currentRowIndex = rowIndex;
|
||
|
||
if (file && file !== currentFile) {
|
||
currentFile = file;
|
||
|
||
// Update preview window if open
|
||
if (previewWindow && !previewWindow.closed) {
|
||
openPreviewWindow(file);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Open preview in a separate popup window
|
||
*/
|
||
async function openPreviewWindow(file) {
|
||
if (!file) return;
|
||
|
||
currentFile = file;
|
||
|
||
try {
|
||
const blob = await getFileBlob(file);
|
||
|
||
// Clean up previous blob URL
|
||
if (currentBlobUrl) {
|
||
URL.revokeObjectURL(currentBlobUrl);
|
||
}
|
||
currentBlobUrl = URL.createObjectURL(blob);
|
||
|
||
const fileName = zddc.joinExtension(file.originalFilename, file.extension);
|
||
|
||
// Build preview HTML with toolbar
|
||
const previewHtml = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>${escapeHtml(fileName)} - Preview</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
}
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.5rem 1rem;
|
||
background: #f5f5f5;
|
||
border-bottom: 1px solid #ddd;
|
||
}
|
||
.toolbar h1 {
|
||
flex: 1;
|
||
font-size: 0.95rem;
|
||
font-weight: 500;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.btn {
|
||
padding: 0.4rem 0.8rem;
|
||
font-size: 0.85rem;
|
||
border: 1px solid #ccc;
|
||
border-radius: 4px;
|
||
background: white;
|
||
cursor: pointer;
|
||
}
|
||
.btn:hover { background: #e8e8e8; }
|
||
iframe, img {
|
||
flex: 1;
|
||
width: 100%;
|
||
border: none;
|
||
}
|
||
img {
|
||
object-fit: contain;
|
||
background: #f0f0f0;
|
||
}
|
||
pre {
|
||
flex: 1;
|
||
padding: 1rem;
|
||
overflow: auto;
|
||
background: #fafafa;
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
font-size: 0.9rem;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
}
|
||
.unsupported {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #666;
|
||
}
|
||
.unsupported .icon { font-size: 3rem; margin-bottom: 1rem; }
|
||
#previewContent {
|
||
flex: 1;
|
||
overflow: auto;
|
||
}
|
||
.loading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
color: #666;
|
||
font-size: 1.1rem;
|
||
}
|
||
.docx-wrapper { padding: 1rem; }
|
||
.xlsx-table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
|
||
.xlsx-table th, .xlsx-table td {
|
||
border: 1px solid #ddd;
|
||
padding: 0.35rem 0.5rem;
|
||
text-align: left;
|
||
white-space: nowrap;
|
||
}
|
||
.xlsx-table th { background: #f0f0f0; font-weight: 600; position: sticky; top: 0; }
|
||
.xlsx-table tr:nth-child(even) { background: #fafafa; }
|
||
.xlsx-table tr:hover { background: #f0f7ff; }
|
||
.sheet-tabs { display: flex; gap: 0; border-bottom: 1px solid #ddd; background: #f5f5f5; }
|
||
.sheet-tab {
|
||
padding: 0.4rem 1rem;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
border-bottom: none;
|
||
font-size: 0.85rem;
|
||
background: transparent;
|
||
}
|
||
.sheet-tab:hover { background: #e8e8e8; }
|
||
.sheet-tab.active {
|
||
background: white;
|
||
border-color: #ddd;
|
||
border-bottom-color: white;
|
||
margin-bottom: -1px;
|
||
font-weight: 500;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="toolbar">
|
||
<h1>${escapeHtml(fileName)}</h1>
|
||
<button class="btn" onclick="downloadFile()">Download</button>
|
||
</div>
|
||
${await getPreviewContent(file, currentBlobUrl)}
|
||
<script>
|
||
var blobUrl = "${currentBlobUrl}";
|
||
var fileName = "${escapeHtml(fileName).replace(/"/g, '\\"')}";
|
||
|
||
function downloadFile() {
|
||
const a = document.createElement('a');
|
||
a.href = blobUrl;
|
||
a.download = fileName;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
}
|
||
<\/script>
|
||
</body>
|
||
</html>`;
|
||
|
||
// Reuse existing window if open, otherwise create new one
|
||
if (previewWindow && !previewWindow.closed) {
|
||
previewWindow.document.open();
|
||
previewWindow.document.write(previewHtml);
|
||
previewWindow.document.close();
|
||
previewWindow.focus();
|
||
} else {
|
||
// Calculate window size
|
||
const width = Math.round(screen.width * 0.6);
|
||
const height = Math.round(screen.height * 0.8);
|
||
const left = Math.round((screen.width - width) / 2);
|
||
const top = Math.round((screen.height - height) / 2);
|
||
|
||
previewWindow = window.open('', 'classifierPreview',
|
||
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`);
|
||
|
||
if (!previewWindow) {
|
||
// Popup blocked - fall back to new tab
|
||
window.open(currentBlobUrl, '_blank');
|
||
return;
|
||
}
|
||
|
||
// Poll for window close — beforeunload is unreliable for popup close buttons
|
||
const closePoll = setInterval(() => {
|
||
if (previewWindow && previewWindow.closed) {
|
||
clearInterval(closePoll);
|
||
previewWindow = null;
|
||
const btn = document.getElementById('togglePreviewBtn');
|
||
if (btn) btn.classList.remove('preview-active');
|
||
}
|
||
}, 500);
|
||
|
||
previewWindow.document.write(previewHtml);
|
||
previewWindow.document.close();
|
||
previewWindow.focus();
|
||
}
|
||
|
||
// For types that need decoding, render content after window is ready
|
||
const ext = (file.extension || '').toLowerCase();
|
||
if (ext === 'docx') {
|
||
await renderDocxInWindow(file);
|
||
} else if (ext === 'xlsx' || ext === 'xls') {
|
||
await renderXlsxInWindow(file);
|
||
} else if (TIFF_EXTENSIONS.includes(ext)) {
|
||
await renderTiffInWindow(file);
|
||
} else if (ZIP_EXTENSIONS.includes(ext)) {
|
||
await renderZipInWindow(file);
|
||
}
|
||
} catch (err) {
|
||
console.error('Error opening preview:', err);
|
||
alert(`Error opening preview: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get preview content HTML based on file type
|
||
*/
|
||
async function getPreviewContent(file, blobUrl) {
|
||
const ext = file.extension.toLowerCase();
|
||
const previewType = getPreviewType(ext);
|
||
|
||
switch (previewType) {
|
||
case 'pdf':
|
||
return `<iframe src="${blobUrl}#view=FitV"></iframe>`;
|
||
case 'html':
|
||
// Render the HTML natively (not as literal text). Sandbox
|
||
// flags allow same-origin resource loads + opening links
|
||
// in real new tabs (target=_blank / middle-click), but
|
||
// NOT allow-scripts — archived HTML cannot run JS.
|
||
return `<iframe src="${blobUrl}" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>`;
|
||
case 'image':
|
||
return `<img src="${blobUrl}" alt="${escapeHtml(file.originalFilename)}" />`;
|
||
case 'text':
|
||
const text = await getFileText(file);
|
||
const maxLength = 100000;
|
||
const displayText = text.length > maxLength
|
||
? text.substring(0, maxLength) + '\n\n... (truncated)'
|
||
: text;
|
||
return `<pre>${escapeHtml(displayText)}</pre>`;
|
||
case 'docx':
|
||
case 'xlsx':
|
||
case 'tiff':
|
||
case 'zip':
|
||
return `<div id="previewContent"><div class="loading">Loading preview...</div></div>`;
|
||
default:
|
||
return `
|
||
<div class="unsupported">
|
||
<div class="icon">📄</div>
|
||
<p>Preview not available for ${ext} files</p>
|
||
<p style="margin-top: 0.5rem;">Click Download to view in external application</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get preview type from extension
|
||
*/
|
||
function getPreviewType(ext) {
|
||
// HTML is technically in TEXT_EXTENSIONS (used for editor
|
||
// syntax-highlighting elsewhere) but for previews we want to
|
||
// RENDER it, not show source. Check before the text branch.
|
||
if (ext === 'html' || ext === 'htm') return 'html';
|
||
if (PDF_EXTENSIONS.includes(ext)) return 'pdf';
|
||
if (TIFF_EXTENSIONS.includes(ext)) return 'tiff';
|
||
if (IMAGE_EXTENSIONS.includes(ext)) return 'image';
|
||
if (TEXT_EXTENSIONS.includes(ext)) return 'text';
|
||
if (ext === 'docx') return 'docx';
|
||
if (ext === 'xlsx' || ext === 'xls') return 'xlsx';
|
||
if (ZIP_EXTENSIONS.includes(ext)) return 'zip';
|
||
return 'none';
|
||
}
|
||
|
||
function getMimeType(ext) {
|
||
return window.app.modules.utils.getMimeType(ext);
|
||
}
|
||
|
||
/**
|
||
* Get file content as blob (handles both real and virtual files)
|
||
*/
|
||
async function getFileBlob(file) {
|
||
if (file.isVirtual) {
|
||
// Get from ZIP cache
|
||
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
|
||
if (!cached) throw new Error('ZIP not found in cache');
|
||
|
||
const zipEntry = cached.zip.file(file.zipEntryPath);
|
||
if (!zipEntry) throw new Error('File not found in ZIP');
|
||
|
||
// Get as arraybuffer and create blob with correct MIME type
|
||
const arrayBuffer = await zipEntry.async('arraybuffer');
|
||
const mimeType = getMimeType(file.extension);
|
||
return new Blob([arrayBuffer], { type: mimeType });
|
||
} else {
|
||
// Get from file handle
|
||
if (!file.handle) {
|
||
throw new Error('File handle not available');
|
||
}
|
||
return await file.handle.getFile();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get file content as text (handles both real and virtual files)
|
||
*/
|
||
async function getFileText(file) {
|
||
if (file.isVirtual) {
|
||
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
|
||
if (!cached) throw new Error('ZIP not found in cache');
|
||
|
||
const zipEntry = cached.zip.file(file.zipEntryPath);
|
||
if (!zipEntry) throw new Error('File not found in ZIP');
|
||
|
||
return await zipEntry.async('string');
|
||
} else {
|
||
if (!file.handle) {
|
||
throw new Error('File handle not available');
|
||
}
|
||
const fileObj = await file.handle.getFile();
|
||
return await fileObj.text();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render a DOCX file in the preview window using docx-preview library
|
||
*/
|
||
async function renderDocxInWindow(file) {
|
||
const container = previewWindow.document.getElementById('previewContent');
|
||
if (!container) return;
|
||
|
||
try {
|
||
await loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js');
|
||
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
|
||
|
||
const blob = await getFileBlob(file);
|
||
const arrayBuffer = await blob.arrayBuffer();
|
||
|
||
container.innerHTML = '';
|
||
await window.docx.renderAsync(arrayBuffer, container);
|
||
} catch (err) {
|
||
console.error('Error rendering DOCX:', err);
|
||
container.innerHTML = `<div class="loading">Error rendering DOCX: ${err.message}<br>Click Download to view in Word.</div>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render an XLSX/XLS file in the preview window using SheetJS
|
||
*/
|
||
async function renderXlsxInWindow(file) {
|
||
const container = previewWindow.document.getElementById('previewContent');
|
||
if (!container) return;
|
||
|
||
try {
|
||
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
|
||
|
||
const blob = await getFileBlob(file);
|
||
const arrayBuffer = await blob.arrayBuffer();
|
||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||
|
||
container.innerHTML = '';
|
||
|
||
if (workbook.SheetNames.length > 1) {
|
||
const tabs = previewWindow.document.createElement('div');
|
||
tabs.className = 'sheet-tabs';
|
||
workbook.SheetNames.forEach((name, i) => {
|
||
const tab = previewWindow.document.createElement('button');
|
||
tab.className = 'sheet-tab' + (i === 0 ? ' active' : '');
|
||
tab.textContent = name;
|
||
tab.onclick = () => {
|
||
tabs.querySelectorAll('.sheet-tab').forEach(t => t.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
renderSheetInWindow(workbook, name, tableContainer);
|
||
};
|
||
tabs.appendChild(tab);
|
||
});
|
||
container.appendChild(tabs);
|
||
}
|
||
|
||
const tableContainer = previewWindow.document.createElement('div');
|
||
tableContainer.style.flex = '1';
|
||
tableContainer.style.overflow = 'auto';
|
||
container.appendChild(tableContainer);
|
||
|
||
renderSheetInWindow(workbook, workbook.SheetNames[0], tableContainer);
|
||
} catch (err) {
|
||
console.error('Error rendering XLSX:', err);
|
||
container.innerHTML = `<div class="loading">Error rendering spreadsheet: ${err.message}<br>Click Download to view in Excel.</div>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render a single sheet as an HTML table in the preview window
|
||
*/
|
||
function renderSheetInWindow(workbook, sheetName, container) {
|
||
const sheet = workbook.Sheets[sheetName];
|
||
const html = XLSX.utils.sheet_to_html(sheet, { editable: false });
|
||
container.innerHTML = html;
|
||
const table = container.querySelector('table');
|
||
if (table) table.className = 'xlsx-table';
|
||
}
|
||
|
||
/**
|
||
* Render a TIFF file in the preview window using shared zddc.preview.renderTiff
|
||
*/
|
||
async function renderTiffInWindow(file) {
|
||
const container = previewWindow.document.getElementById('previewContent');
|
||
if (!container) return;
|
||
try {
|
||
const blob = await getFileBlob(file);
|
||
const arrayBuffer = await blob.arrayBuffer();
|
||
await zddc.preview.renderTiff(previewWindow.document, container, arrayBuffer, {
|
||
fileName: zddc.joinExtension(file.originalFilename, file.extension)
|
||
});
|
||
} catch (err) {
|
||
console.error('Error rendering TIFF:', err);
|
||
container.innerHTML = `<div class="loading">Error rendering TIFF: ${err.message}<br>Click Download to view in another application.</div>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render a ZIP listing in the preview window using shared zddc.preview.renderZipListing
|
||
*/
|
||
async function renderZipInWindow(file) {
|
||
const container = previewWindow.document.getElementById('previewContent');
|
||
if (!container) return;
|
||
try {
|
||
const blob = await getFileBlob(file);
|
||
const arrayBuffer = await blob.arrayBuffer();
|
||
await zddc.preview.renderZipListing(previewWindow.document, container, arrayBuffer, {
|
||
fileName: zddc.joinExtension(file.originalFilename, file.extension)
|
||
});
|
||
} catch (err) {
|
||
console.error('Error rendering ZIP listing:', err);
|
||
container.innerHTML = `<div class="loading">Error reading ZIP: ${err.message}</div>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Escape HTML for safe display
|
||
*/
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
/**
|
||
* Download current file
|
||
*/
|
||
async function downloadFile() {
|
||
if (!currentFile) return;
|
||
|
||
try {
|
||
const blob = await getFileBlob(currentFile);
|
||
const url = URL.createObjectURL(blob);
|
||
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = zddc.joinExtension(currentFile.originalFilename, currentFile.extension);
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
} catch (err) {
|
||
console.error('Error downloading file:', err);
|
||
alert('Error downloading file: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// Export module
|
||
window.app.modules.preview = {
|
||
init
|
||
};
|
||
})();
|
||
|
||
/**
|
||
* Column Resize Module
|
||
* Handles resizable table columns
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
let resizingColumn = null;
|
||
let startX = 0;
|
||
let startWidth = 0;
|
||
|
||
/**
|
||
* Initialize column resizing
|
||
*/
|
||
function init() {
|
||
const table = window.app.dom.spreadsheet;
|
||
const headers = table.querySelectorAll('thead th');
|
||
|
||
headers.forEach(th => {
|
||
// Skip if resize handle already exists
|
||
if (th.querySelector('.column-resizer')) return;
|
||
|
||
// Add resize handle
|
||
const resizer = document.createElement('div');
|
||
resizer.className = 'column-resizer';
|
||
th.appendChild(resizer);
|
||
|
||
// Mouse down on resizer
|
||
resizer.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
resizingColumn = th;
|
||
startX = e.pageX;
|
||
startWidth = th.offsetWidth;
|
||
|
||
document.addEventListener('mousemove', handleMouseMove);
|
||
document.addEventListener('mouseup', handleMouseUp);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle mouse move during resize
|
||
*/
|
||
function handleMouseMove(e) {
|
||
if (!resizingColumn) return;
|
||
|
||
const diff = e.pageX - startX;
|
||
const newWidth = Math.max(50, startWidth + diff);
|
||
|
||
resizingColumn.style.width = newWidth + 'px';
|
||
resizingColumn.style.minWidth = newWidth + 'px';
|
||
resizingColumn.style.maxWidth = newWidth + 'px';
|
||
}
|
||
|
||
/**
|
||
* Handle mouse up - end resize
|
||
*/
|
||
function handleMouseUp() {
|
||
resizingColumn = null;
|
||
document.removeEventListener('mousemove', handleMouseMove);
|
||
document.removeEventListener('mouseup', handleMouseUp);
|
||
}
|
||
|
||
// Export module
|
||
window.app.modules.resize = {
|
||
init
|
||
};
|
||
})();
|
||
|
||
/**
|
||
* Filter Module
|
||
* Column filter UI: initialises static inputs in thead, wires events.
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
/**
|
||
* Initialize filtering — wire delegated events on thead.
|
||
* Filter inputs already exist in the static template; no dynamic injection needed.
|
||
*/
|
||
function init() {
|
||
const thead = window.app.dom.spreadsheet
|
||
? window.app.dom.spreadsheet.querySelector('thead')
|
||
: document.querySelector('#spreadsheet thead');
|
||
if (!thead) return;
|
||
|
||
thead.addEventListener('input', (e) => {
|
||
if (e.target.matches('.column-filter[data-filter-field]')) {
|
||
e.stopPropagation();
|
||
const field = e.target.getAttribute('data-filter-field');
|
||
const raw = e.target.value.trim();
|
||
const ast = window.zddc.filter.parse(raw);
|
||
window.app.modules.store.setFilter(field, raw, ast);
|
||
}
|
||
});
|
||
|
||
thead.addEventListener('keydown', (e) => {
|
||
if (!e.target.matches('.column-filter[data-filter-field]')) return;
|
||
if (e.key === 'Escape') {
|
||
e.target.value = '';
|
||
const field = e.target.getAttribute('data-filter-field');
|
||
window.app.modules.store.setFilter(field, '', null);
|
||
e.preventDefault();
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const inputs = Array.from(thead.querySelectorAll('.column-filter'));
|
||
const idx = inputs.indexOf(e.target);
|
||
if (idx !== -1) inputs[(idx + 1) % inputs.length].focus();
|
||
}
|
||
});
|
||
|
||
thead.addEventListener('click', (e) => {
|
||
if (e.target.matches('.column-filter')) {
|
||
e.stopPropagation();
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Clear all filters — reset inputs and store.
|
||
*/
|
||
function clearFilters() {
|
||
document.querySelectorAll('.column-filter').forEach(input => {
|
||
input.value = '';
|
||
});
|
||
window.app.modules.store.setAllFilters({});
|
||
}
|
||
|
||
window.app.modules.filter = {
|
||
init,
|
||
clearFilters
|
||
};
|
||
})();
|
||
|
||
/**
|
||
* Sort Module
|
||
* Handles multi-column sorting for the spreadsheet
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
// Sort state: array of {column, direction}
|
||
let sortState = [];
|
||
|
||
/**
|
||
* Initialize sorting
|
||
*/
|
||
function init() {
|
||
const table = window.app.dom.spreadsheet;
|
||
const headers = table.querySelectorAll('thead th');
|
||
|
||
headers.forEach((th, index) => {
|
||
// Skip row number column
|
||
if (th.classList.contains('col-row-num')) return;
|
||
|
||
// Skip if already initialized
|
||
if (th.querySelector('.sort-indicator')) return;
|
||
|
||
// Make header clickable
|
||
th.style.cursor = 'pointer';
|
||
th.style.userSelect = 'none';
|
||
|
||
// Add sort indicator container
|
||
const sortIndicator = document.createElement('span');
|
||
sortIndicator.className = 'sort-indicator';
|
||
|
||
// Insert after the text node (before any br or filter)
|
||
const firstChild = th.firstChild;
|
||
if (firstChild && firstChild.nodeType === Node.TEXT_NODE) {
|
||
// Insert after text node
|
||
firstChild.after(sortIndicator);
|
||
} else {
|
||
// Prepend to header
|
||
th.insertBefore(sortIndicator, firstChild);
|
||
}
|
||
|
||
// Click to sort (only add once)
|
||
const handleClick = (e) => {
|
||
// Don't sort if clicking on resizer or filter input
|
||
if (e.target.classList.contains('column-resizer')) return;
|
||
if (e.target.classList.contains('column-filter')) return;
|
||
|
||
const columnName = th.className.replace('col-', '');
|
||
handleSort(columnName, e.ctrlKey || e.metaKey);
|
||
};
|
||
|
||
th.addEventListener('click', handleClick);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Apply default sort (called after initial render)
|
||
*/
|
||
function applyDefaultSort() {
|
||
const files = window.app.modules.store.getDisplayFiles();
|
||
if (files.length > 0) {
|
||
sortState = [{ column: 'original', direction: 'asc' }];
|
||
applySorts();
|
||
updateSortIndicators();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle sort click
|
||
*/
|
||
function handleSort(columnName, multiSort) {
|
||
// Use store to toggle sort
|
||
window.app.modules.store.toggleSort(columnName, multiSort);
|
||
}
|
||
|
||
/**
|
||
* Apply sort to files array (pure function - doesn't mutate)
|
||
*/
|
||
function applySortToFiles(files) {
|
||
if (sortState.length === 0) {
|
||
return files;
|
||
}
|
||
|
||
return [...files].sort((a, b) => {
|
||
for (const sort of sortState) {
|
||
const result = compareValues(a, b, sort.column, sort.direction);
|
||
if (result !== 0) return result;
|
||
}
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Apply all sorts (legacy - triggers render)
|
||
*/
|
||
function applySorts() {
|
||
window.app.modules.spreadsheet.render();
|
||
}
|
||
|
||
/**
|
||
* Apply default sort
|
||
*/
|
||
function applyDefaultSort() {
|
||
const files = window.app.modules.store.getDisplayFiles();
|
||
if (files.length > 0 && sortState.length === 0) {
|
||
sortState = [{ column: 'original', direction: 'asc' }];
|
||
window.app.modules.spreadsheet.render();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get current sort state
|
||
*/
|
||
function getSortState() {
|
||
return sortState;
|
||
}
|
||
|
||
/**
|
||
* Update sort indicators in headers
|
||
*/
|
||
function updateIndicators() {
|
||
const table = window.app.dom.spreadsheet;
|
||
const headers = table.querySelectorAll('thead th');
|
||
|
||
headers.forEach(th => {
|
||
const indicator = th.querySelector('.sort-indicator');
|
||
if (!indicator) return;
|
||
|
||
const columnName = th.className.replace('col-', '');
|
||
const sortIndex = sortState.findIndex(s => s.column === columnName);
|
||
|
||
if (sortIndex >= 0) {
|
||
const sort = sortState[sortIndex];
|
||
const arrow = sort.direction === 'asc' ? '▲' : '▼';
|
||
const priority = sortState.length > 1 ? (sortIndex + 1) : '';
|
||
indicator.textContent = ` ${arrow}${priority}`;
|
||
indicator.style.display = 'inline';
|
||
} else {
|
||
indicator.textContent = '';
|
||
indicator.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Compare two values for sorting
|
||
*/
|
||
function compareValues(a, b, columnName, direction) {
|
||
let aVal, bVal;
|
||
|
||
// Get values based on column
|
||
switch (columnName) {
|
||
case 'original':
|
||
aVal = a.originalFilename || '';
|
||
bVal = b.originalFilename || '';
|
||
break;
|
||
case 'extension':
|
||
aVal = a.extension || '';
|
||
bVal = b.extension || '';
|
||
break;
|
||
case 'new':
|
||
case 'newFilename':
|
||
aVal = a.manualFilename || window.app.modules.spreadsheet.computeNewFilename(a, 0);
|
||
bVal = b.manualFilename || window.app.modules.spreadsheet.computeNewFilename(b, 0);
|
||
break;
|
||
case 'trackingNumber':
|
||
aVal = a.trackingNumber || '';
|
||
bVal = b.trackingNumber || '';
|
||
break;
|
||
case 'revision':
|
||
aVal = a.revision || '';
|
||
bVal = b.revision || '';
|
||
break;
|
||
case 'status':
|
||
aVal = a.status || '';
|
||
bVal = b.status || '';
|
||
break;
|
||
case 'title':
|
||
aVal = a.title || '';
|
||
bVal = b.title || '';
|
||
break;
|
||
case 'sha256':
|
||
aVal = a.sha256 || '';
|
||
bVal = b.sha256 || '';
|
||
break;
|
||
default:
|
||
return 0;
|
||
}
|
||
|
||
// Natural sort for strings (handles numbers within strings)
|
||
const comparison = aVal.localeCompare(bVal, undefined, { numeric: true, sensitivity: 'base' });
|
||
|
||
return direction === 'asc' ? comparison : -comparison;
|
||
}
|
||
|
||
/**
|
||
* Clear all sorts
|
||
*/
|
||
function clearSorts() {
|
||
sortState = [];
|
||
applySorts();
|
||
}
|
||
|
||
// Export module
|
||
window.app.modules.sort = {
|
||
init,
|
||
applyDefaultSort,
|
||
applySorts,
|
||
applySortToFiles,
|
||
updateIndicators,
|
||
getSortState,
|
||
clearSorts
|
||
};
|
||
})();
|
||
|
||
/**
|
||
* Excel Integration Module
|
||
* Toast notifications and hash export
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
/**
|
||
* Show toast notification
|
||
*/
|
||
function showToast(message, type = 'info') {
|
||
// Remove existing toast
|
||
const existing = document.querySelector('.toast');
|
||
if (existing) {
|
||
existing.remove();
|
||
}
|
||
|
||
// Create toast
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast toast-${type}`;
|
||
toast.textContent = message;
|
||
document.body.appendChild(toast);
|
||
|
||
// Auto-remove after 5 seconds
|
||
setTimeout(() => {
|
||
toast.classList.add('toast-fade');
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 5000);
|
||
}
|
||
|
||
/**
|
||
* Export SHA256 hashes in sha256sum format
|
||
*/
|
||
async function exportHashes() {
|
||
const files = window.app.modules.store.getDisplayFiles();
|
||
if (files.length === 0) {
|
||
alert('No files to export');
|
||
return;
|
||
}
|
||
|
||
// Check if SHA256 is enabled
|
||
if (!window.app.calculateSha256) {
|
||
alert('Please enable SHA256 checkbox first and wait for hashes to calculate');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Build sha256sum format: hash *filepath
|
||
const lines = [];
|
||
|
||
// Get root path
|
||
const rootPath = await getFullPath(window.app.rootHandle);
|
||
|
||
for (const file of files) {
|
||
if (!file.sha256 || file.sha256 === 'calculating...' || file.sha256 === 'error') {
|
||
continue; // Skip files without calculated hash
|
||
}
|
||
|
||
// Get full path from root
|
||
const folderPath = await getFullPath(file.folderHandle);
|
||
const fullPath = `${folderPath}/${zddc.joinExtension(file.originalFilename, file.extension)}`;
|
||
|
||
// Format: hash *filepath (asterisk indicates binary mode)
|
||
lines.push(`${file.sha256} *${fullPath}`);
|
||
}
|
||
|
||
if (lines.length === 0) {
|
||
alert('No SHA256 hashes available. Enable SHA256 and wait for calculation to complete.');
|
||
return;
|
||
}
|
||
|
||
// Create output
|
||
const output = lines.join('\n');
|
||
|
||
// Generate filename with timestamp
|
||
const now = new Date();
|
||
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, -5); // YYYY-MM-DDTHH-MM-SS
|
||
const filename = `sha256sums_${timestamp}.txt`;
|
||
|
||
// Download as file
|
||
const blob = new Blob([output], { type: 'text/plain' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
// Show success message
|
||
showToast(`✓ Downloaded ${lines.length} hash(es) to ${filename}`, 'success');
|
||
|
||
} catch (err) {
|
||
console.error('Error exporting hashes:', err);
|
||
alert('Error exporting hashes: ' + err.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get full path from directory handle (all the way to root)
|
||
*/
|
||
async function getFullPath(dirHandle) {
|
||
const parts = [];
|
||
let current = dirHandle;
|
||
|
||
// Walk up to root - collect ALL parent folders
|
||
while (current) {
|
||
parts.unshift(current.name);
|
||
|
||
try {
|
||
// Try to get parent
|
||
if (typeof current.getParent === 'function') {
|
||
const parent = await current.getParent();
|
||
if (parent && parent !== current) {
|
||
current = parent;
|
||
continue;
|
||
}
|
||
}
|
||
break;
|
||
} catch {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return parts.join('/');
|
||
}
|
||
|
||
// Export module
|
||
window.app.modules.excel = {
|
||
showToast,
|
||
exportHashes
|
||
};
|
||
})();
|
||
|
||
/**
|
||
* ZDDC shared help panel — open/close logic.
|
||
* Works with all four tools regardless of their module pattern.
|
||
* Expects: #help-btn, #help-panel, #help-panel-close in the DOM.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
function init() {
|
||
var helpBtn = document.getElementById('help-btn');
|
||
var panel = document.getElementById('help-panel');
|
||
var closeBtn = document.getElementById('help-panel-close');
|
||
|
||
if (!helpBtn || !panel) { return; }
|
||
|
||
function isOpen() { return !panel.hidden; }
|
||
|
||
function openPanel() {
|
||
panel.hidden = false;
|
||
document.body.classList.add('help-open');
|
||
}
|
||
|
||
function closePanel() {
|
||
panel.hidden = true;
|
||
document.body.classList.remove('help-open');
|
||
}
|
||
|
||
helpBtn.addEventListener('click', function () {
|
||
if (isOpen()) { closePanel(); } else { openPanel(); }
|
||
});
|
||
|
||
if (closeBtn) {
|
||
closeBtn.addEventListener('click', closePanel);
|
||
}
|
||
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape' && isOpen()) { closePanel(); }
|
||
});
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
}());
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|