ZDDC — Zero Day Document Control. A file-naming convention plus five single-file HTML tools (archive, transmittal, classifier, mdedit, landing) and an optional Go HTTP server (zddc-server) with ACL and a virtual archive index. Self-contained, offline-capable, dependency-free. See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the build/release/architecture detail, bootstrap/README.md for the two-level deployment install pattern, and zddc/README.md for the HTTP server.
6971 lines
218 KiB
HTML
6971 lines
218 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>
|
|
<style>
|
|
/* ==========================================================================
|
|
ZDDC Shared Base — single source of truth for tokens and primitives
|
|
Included first by every tool's build.sh via ../shared/base.css
|
|
========================================================================== */
|
|
|
|
/* ── CSS custom properties ────────────────────────────────────────────────── */
|
|
:root {
|
|
/* Brand / accent (matches zddc.varasys.io website --accent) */
|
|
--primary: #2a5a8a;
|
|
--primary-hover: #1d4060;
|
|
--primary-active: #163352;
|
|
--primary-light: #e8f0f7;
|
|
|
|
/* Semantic colours */
|
|
--success: #28a745;
|
|
--warning: #d97706;
|
|
--danger: #dc3545;
|
|
--info: #17a2b8;
|
|
|
|
/* Backgrounds */
|
|
--bg: #ffffff;
|
|
--bg-secondary: #f8f9fa;
|
|
--bg-hover: #f0f4f8;
|
|
--bg-selected: var(--primary-light);
|
|
|
|
/* Text */
|
|
--text: #212529;
|
|
--text-muted: #6c757d;
|
|
--text-light: #ffffff;
|
|
|
|
/* Borders */
|
|
--border: #dee2e6;
|
|
--border-dark: #adb5bd;
|
|
|
|
/* Shape */
|
|
--radius: 4px;
|
|
|
|
/* Typography */
|
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
|
|
}
|
|
|
|
/* ── Dark mode tokens ─────────────────────────────────────────────────────── */
|
|
/* Applied via: OS preference (auto) or [data-theme="dark"] on <html> */
|
|
/* The [data-theme="light"] selector locks light mode regardless of OS pref. */
|
|
@media (prefers-color-scheme: dark) {
|
|
:root:not([data-theme="light"]) {
|
|
--primary: #4a90c4;
|
|
--primary-hover: #5ba3d9;
|
|
--primary-active: #6ab5e8;
|
|
--primary-light: #1a3550;
|
|
|
|
--bg: #1e1e1e;
|
|
--bg-secondary: #252526;
|
|
--bg-hover: #2d2d30;
|
|
--bg-selected: #1a3550;
|
|
|
|
--text: #d4d4d4;
|
|
--text-muted: #9d9d9d;
|
|
--text-light: #ffffff;
|
|
|
|
--border: #3e3e42;
|
|
--border-dark: #6e6e72;
|
|
}
|
|
}
|
|
|
|
/* Manual dark override — wins over media query */
|
|
[data-theme="dark"] {
|
|
--primary: #4a90c4;
|
|
--primary-hover: #5ba3d9;
|
|
--primary-active: #6ab5e8;
|
|
--primary-light: #1a3550;
|
|
|
|
--bg: #1e1e1e;
|
|
--bg-secondary: #252526;
|
|
--bg-hover: #2d2d30;
|
|
--bg-selected: #1a3550;
|
|
|
|
--text: #d4d4d4;
|
|
--text-muted: #9d9d9d;
|
|
--text-light: #ffffff;
|
|
|
|
--border: #3e3e42;
|
|
--border-dark: #6e6e72;
|
|
}
|
|
|
|
/* ── Reset ────────────────────────────────────────────────────────────────── */
|
|
*, *::before, *::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
/* ── Base document ────────────────────────────────────────────────────────── */
|
|
html, body {
|
|
height: 100%;
|
|
font-family: var(--font);
|
|
font-size: 16px;
|
|
line-height: 1.5;
|
|
color: var(--text);
|
|
background-color: var(--bg-secondary);
|
|
}
|
|
|
|
/* ── Typography ───────────────────────────────────────────────────────────── */
|
|
h1, h2, h3, h4, h5, h6 {
|
|
font-weight: 600;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
a {
|
|
color: var(--primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* ── Utility ──────────────────────────────────────────────────────────────── */
|
|
.hidden {
|
|
display: none !important;
|
|
}
|
|
|
|
.truncate {
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
/* ── Scrollbars (webkit) ──────────────────────────────────────────────────── */
|
|
::-webkit-scrollbar {
|
|
width: 7px;
|
|
height: 7px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: #c1c1c1;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: #a0a0a0;
|
|
}
|
|
|
|
/* ── Button primitive ─────────────────────────────────────────────────────── */
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
padding: 0.4rem 0.85rem;
|
|
font-family: var(--font);
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
line-height: 1.4;
|
|
text-align: center;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
vertical-align: middle;
|
|
cursor: pointer;
|
|
border: 1px solid transparent;
|
|
border-radius: var(--radius);
|
|
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, color 0.15s;
|
|
background: var(--bg-secondary);
|
|
color: var(--text);
|
|
}
|
|
|
|
.btn:disabled,
|
|
.btn[disabled] {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn:not(:disabled):hover {
|
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
|
}
|
|
|
|
.btn:not(:disabled):active {
|
|
box-shadow: none;
|
|
}
|
|
|
|
/* Variants */
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
color: var(--text-light);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.btn-primary:not(:disabled):hover {
|
|
background: var(--primary-hover);
|
|
border-color: var(--primary-hover);
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.btn-primary:not(:disabled):active {
|
|
background: var(--primary-active);
|
|
border-color: var(--primary-active);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
border-color: var(--border);
|
|
}
|
|
|
|
.btn-secondary:not(:disabled):hover {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.btn-success {
|
|
background: var(--success);
|
|
color: var(--text-light);
|
|
border-color: var(--success);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: var(--danger);
|
|
color: var(--text-light);
|
|
border-color: var(--danger);
|
|
}
|
|
|
|
/* Sizes */
|
|
.btn-sm {
|
|
padding: 0.25rem 0.5rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.btn-lg {
|
|
padding: 0.6rem 1.4rem;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.btn-link {
|
|
background: transparent;
|
|
border-color: transparent;
|
|
color: var(--primary);
|
|
padding-left: 0;
|
|
padding-right: 0;
|
|
}
|
|
|
|
.btn-link:not(:disabled):hover {
|
|
text-decoration: underline;
|
|
box-shadow: none;
|
|
}
|
|
|
|
/* ── App header chrome ────────────────────────────────────────────────────── */
|
|
.app-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.35rem 1rem;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Tool name inside the header */
|
|
.app-header__title {
|
|
font-size: 17px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
letter-spacing: 0.01em;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Build timestamp ──────────────────────────────────────────────────────── */
|
|
.build-timestamp {
|
|
font-size: 0.55rem;
|
|
color: var(--text-muted);
|
|
opacity: 0.7;
|
|
font-weight: 300;
|
|
white-space: nowrap;
|
|
padding-top: 0.15rem;
|
|
}
|
|
|
|
/* Title + timestamp stacked vertically on the left side of the header */
|
|
.header-title-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
line-height: 1;
|
|
}
|
|
|
|
/* ── Icon buttons (help, theme, refresh) ─────────────────────────────────── */
|
|
/* Square, centered — overrides the asymmetric text-button padding/line-height */
|
|
#help-btn,
|
|
#theme-btn,
|
|
#refreshHeaderBtn {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
padding: 0;
|
|
line-height: 1;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
|
|
|
|
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
|
|
#theme-btn,
|
|
#help-btn {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
/* ── Help panel (shared slide-out drawer) ─────────────────────────────────── */
|
|
/* Used by all four tools. Toggle open/close via shared/help.js. */
|
|
|
|
.help-panel {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
width: min(420px, 85vw);
|
|
height: 100vh;
|
|
z-index: 1000;
|
|
background: var(--bg);
|
|
border-left: 1px solid var(--border);
|
|
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.08);
|
|
display: flex;
|
|
flex-direction: column;
|
|
transform: translateX(100%);
|
|
transition: transform 0.25s ease;
|
|
}
|
|
|
|
.help-panel:not([hidden]) {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.help-panel[hidden] {
|
|
display: flex;
|
|
transform: translateX(100%);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.help-panel__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.75rem 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.help-panel__title {
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
margin: 0;
|
|
}
|
|
|
|
.help-panel__close {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
font-size: 1.35rem;
|
|
cursor: pointer;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: var(--radius);
|
|
line-height: 1;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
|
|
.help-panel__close:hover {
|
|
color: var(--text);
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.help-panel__body {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem 1rem 2rem;
|
|
font-size: 0.85rem;
|
|
line-height: 1.6;
|
|
color: var(--text);
|
|
}
|
|
|
|
.help-panel__body h3 {
|
|
font-size: 0.95rem;
|
|
font-weight: 700;
|
|
margin: 1.25rem 0 0.35rem;
|
|
color: var(--text);
|
|
border-bottom: 1px solid var(--border);
|
|
padding-bottom: 0.15rem;
|
|
}
|
|
|
|
.help-panel__body h3:first-child {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.help-panel__body h4 {
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
margin: 1.25rem 0 0.3rem;
|
|
padding-left: 0.5rem;
|
|
border-left: 3px solid var(--border-dark);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.help-panel__body p {
|
|
margin: 0 0 0.5rem;
|
|
}
|
|
|
|
.help-panel__body ol,
|
|
.help-panel__body ul {
|
|
padding-left: 1.5rem;
|
|
margin: 0.3rem 0 0.5rem;
|
|
}
|
|
|
|
.help-panel__body li {
|
|
margin-bottom: 0.3rem;
|
|
}
|
|
|
|
.help-panel__body dl {
|
|
margin: 0.3rem 0;
|
|
}
|
|
|
|
.help-panel__body dt {
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
|
|
.help-panel__body dd {
|
|
margin: 0 0 0.5rem 1rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.help-panel__body code {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.8em;
|
|
background: var(--bg-secondary);
|
|
padding: 0.1em 0.3em;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.help-badge {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
padding: 0.1rem 0.35rem;
|
|
border-radius: var(--radius);
|
|
vertical-align: middle;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.help-badge--draft {
|
|
color: #2563eb;
|
|
background: #eff6ff;
|
|
}
|
|
|
|
.help-badge--published {
|
|
color: #7c3aed;
|
|
background: #f5f3ff;
|
|
}
|
|
|
|
/* Shrink main content when help panel is open */
|
|
body.help-open .app-header {
|
|
margin-right: min(420px, 85vw);
|
|
}
|
|
|
|
/* ── Column filter inputs (shared across archive, classifier, transmittal) ─── */
|
|
.column-filter {
|
|
display: block;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
margin-top: 0.25rem;
|
|
padding: 0.2rem 0.4rem;
|
|
font-size: 0.8rem;
|
|
font-family: var(--font);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
transition: border-color 0.15s;
|
|
}
|
|
|
|
.column-filter:focus {
|
|
border-color: var(--primary);
|
|
outline: none;
|
|
box-shadow: 0 0 0 1px rgba(42, 90, 138, 0.35);
|
|
}
|
|
|
|
.column-filter::placeholder {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Classifier-specific base overrides
|
|
Reset, tokens, buttons, and font are provided by shared/base.css */
|
|
|
|
#app {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* Utility */
|
|
.text-muted { color: var(--text-muted); }
|
|
.text-success { color: var(--success); }
|
|
.text-warning { color: var(--warning); }
|
|
.text-danger { color: var(--danger); }
|
|
|
|
/* Checkbox label */
|
|
.checkbox-label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.checkbox-label input[type="checkbox"] {
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* ── Toast notifications (classifier-only) ───────────────────────────────── */
|
|
/* shared/base.css intentionally omits toast CSS; only classifier uses toasts. */
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 2rem;
|
|
right: 2rem;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
padding: 0.875rem 1.25rem;
|
|
border-radius: var(--radius);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
z-index: 9000;
|
|
max-width: 400px;
|
|
font-size: 0.875rem;
|
|
animation: zddc-toast-in 0.3s ease-out;
|
|
}
|
|
|
|
.toast-success { border-left: 4px solid var(--success); }
|
|
.toast-error { border-left: 4px solid var(--danger); }
|
|
.toast-info { border-left: 4px solid var(--info); }
|
|
.toast-warning { border-left: 4px solid var(--warning); }
|
|
|
|
.toast-fade {
|
|
animation: zddc-toast-out 0.3s ease-out forwards;
|
|
}
|
|
|
|
@keyframes zddc-toast-in {
|
|
from { transform: translateX(100%); opacity: 0; }
|
|
to { transform: translateX(0); opacity: 1; }
|
|
}
|
|
|
|
@keyframes zddc-toast-out {
|
|
from { transform: translateX(0); opacity: 1; }
|
|
to { transform: translateX(100%); opacity: 0; }
|
|
}
|
|
|
|
/* Classifier layout — tokens from shared/base.css */
|
|
|
|
/* Empty State — positioned below the app header */
|
|
.empty-state {
|
|
position: absolute;
|
|
top: 50px; /* clear the header */
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--bg);
|
|
z-index: 10;
|
|
}
|
|
|
|
.empty-state-content {
|
|
text-align: center;
|
|
max-width: 500px;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.empty-state-content h2 {
|
|
color: var(--text);
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.empty-state-content p {
|
|
margin-bottom: 1rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.empty-state-content .note {
|
|
font-size: 0.85rem;
|
|
font-style: italic;
|
|
}
|
|
|
|
.welcome-list {
|
|
text-align: left;
|
|
margin: 0.5rem auto;
|
|
max-width: 400px;
|
|
}
|
|
|
|
.empty-state.drag-over {
|
|
background: var(--primary-light);
|
|
outline: 2px dashed var(--primary);
|
|
outline-offset: -4px;
|
|
}
|
|
|
|
/* Browser Warning */
|
|
.browser-warning {
|
|
background-color: rgba(217, 119, 6, 0.08);
|
|
border: 2px solid var(--warning);
|
|
border-radius: var(--radius);
|
|
padding: 1.5rem;
|
|
margin: 1.5rem 0;
|
|
text-align: left;
|
|
}
|
|
|
|
.browser-warning h3 {
|
|
color: var(--warning);
|
|
margin-top: 0;
|
|
}
|
|
|
|
.browser-warning ul {
|
|
margin: 0.5rem 0;
|
|
padding-left: 1.5rem;
|
|
}
|
|
|
|
/* Main App */
|
|
.main-app {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
background-color: var(--bg);
|
|
position: relative;
|
|
}
|
|
|
|
/* Header — shared/base.css provides .app-header base */
|
|
.app-header {
|
|
padding: 0.5rem 1rem;
|
|
}
|
|
|
|
.header-left,
|
|
.header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.header-divider {
|
|
color: var(--border);
|
|
margin: 0 0.25rem;
|
|
}
|
|
|
|
/* Main Content */
|
|
.main-content {
|
|
display: flex;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Folder Tree Pane */
|
|
.folder-tree-pane {
|
|
width: 300px;
|
|
min-width: 150px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background-color: var(--bg-secondary);
|
|
border-right: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
position: relative;
|
|
transition: width 0.2s ease, min-width 0.2s ease;
|
|
}
|
|
|
|
.folder-tree-pane.collapsed {
|
|
width: 40px !important;
|
|
min-width: 40px !important;
|
|
max-width: 40px !important;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.folder-tree-pane.collapsed .pane-header-controls,
|
|
.folder-tree-pane.collapsed .folder-tree,
|
|
.folder-tree-pane.collapsed .pane-header h3 {
|
|
display: none;
|
|
}
|
|
|
|
.folder-tree-pane.collapsed .pane-header {
|
|
padding: 0.5rem;
|
|
justify-content: center;
|
|
}
|
|
|
|
.folder-tree-pane.collapsed .pane-header-title {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.pane-header-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.collapse-tree-btn {
|
|
padding: 0.25rem 0.5rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
/* Resize Handle */
|
|
.resize-handle {
|
|
position: absolute;
|
|
right: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 5px;
|
|
cursor: col-resize;
|
|
background-color: transparent;
|
|
z-index: 10;
|
|
}
|
|
|
|
.resize-handle:hover {
|
|
background-color: var(--primary);
|
|
}
|
|
|
|
.pane-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.75rem 1rem;
|
|
background-color: var(--bg);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.pane-header-left,
|
|
.pane-header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.pane-header h3 {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.pane-header-controls {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.folder-stats,
|
|
.file-stats {
|
|
display: flex;
|
|
gap: 1rem;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.folder-tree {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
/* Folder Item */
|
|
.folder-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0.5rem;
|
|
cursor: pointer;
|
|
border-radius: var(--radius);
|
|
user-select: none;
|
|
transition: background-color 0.15s;
|
|
}
|
|
|
|
.folder-item:hover {
|
|
background-color: var(--bg-hover);
|
|
}
|
|
|
|
.folder-item.selected {
|
|
background-color: var(--bg-selected);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.folder-item.folder-hover-highlight {
|
|
background-color: rgba(217, 119, 6, 0.12);
|
|
border-left: 3px solid var(--warning);
|
|
transition: background-color 0.2s, border-left 0.2s;
|
|
}
|
|
|
|
.folder-item.has-unsaved {
|
|
border-left: 3px solid var(--warning);
|
|
}
|
|
|
|
.folder-toggle {
|
|
width: 20px;
|
|
height: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.folder-icon {
|
|
margin-right: 0.5rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.folder-name {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.folder-count {
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.folder-children {
|
|
margin-left: 1.5rem;
|
|
}
|
|
|
|
/* Spreadsheet Pane */
|
|
.spreadsheet-pane {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.spreadsheet-container {
|
|
flex: 1;
|
|
overflow: auto;
|
|
background-color: var(--bg);
|
|
}
|
|
|
|
/* ZIP Extract Button in Tree */
|
|
.zip-extract-btn {
|
|
margin-left: auto;
|
|
padding: 0.15rem 0.4rem;
|
|
font-size: 0.7rem;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.folder-item:hover .zip-extract-btn {
|
|
opacity: 1;
|
|
}
|
|
|
|
.zip-extract-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: wait;
|
|
}
|
|
|
|
/* ZIP Extract All Button */
|
|
.zip-extract-all-btn {
|
|
margin-left: auto;
|
|
padding: 0.15rem 0.4rem;
|
|
font-size: 0.7rem;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.folder-item:hover .zip-extract-all-btn {
|
|
opacity: 1;
|
|
}
|
|
|
|
.zip-extract-all-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: wait;
|
|
}
|
|
|
|
/**
|
|
* Spreadsheet Styles
|
|
* Table, cells, editing, and row states
|
|
*/
|
|
|
|
.spreadsheet {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.875rem;
|
|
background-color: var(--bg);
|
|
}
|
|
|
|
/* Selected cells */
|
|
.selected-cell {
|
|
background-color: rgba(0, 123, 255, 0.2) !important;
|
|
outline: 1px solid var(--primary);
|
|
}
|
|
|
|
/* Auto-populated cells (gray text to indicate matches filename) */
|
|
.cell-editable.auto-populated {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Changed fields (blue text to indicate value differs from original filename) */
|
|
.cell-editable.field-changed {
|
|
color: var(--primary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.spreadsheet thead {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
background-color: var(--bg-secondary);
|
|
}
|
|
|
|
.spreadsheet th {
|
|
padding: 0.75rem 0.5rem;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
border-bottom: 2px solid var(--border);
|
|
background-color: var(--bg-secondary);
|
|
position: relative;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.spreadsheet th:hover:not(.col-row-num) {
|
|
background-color: var(--border);
|
|
}
|
|
|
|
/* Sort indicator */
|
|
.sort-indicator {
|
|
display: inline-block;
|
|
font-size: 0.75rem;
|
|
color: var(--primary);
|
|
margin-left: 0.25rem;
|
|
margin-right: 0.25rem;
|
|
font-weight: bold;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.spreadsheet td {
|
|
padding: 0.5rem;
|
|
border-bottom: 1px solid var(--border);
|
|
vertical-align: top;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* Column resizer */
|
|
.column-resizer {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
width: 5px;
|
|
height: 100%;
|
|
cursor: col-resize;
|
|
user-select: none;
|
|
z-index: 1;
|
|
}
|
|
|
|
.column-resizer:hover {
|
|
background-color: var(--primary);
|
|
}
|
|
|
|
/* Column Widths */
|
|
.col-row-num {
|
|
width: 50px;
|
|
text-align: center;
|
|
background-color: var(--bg-secondary);
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
|
|
.col-original {
|
|
min-width: 250px;
|
|
}
|
|
|
|
.col-extension {
|
|
width: 60px;
|
|
text-align: center;
|
|
}
|
|
|
|
.col-new {
|
|
min-width: 250px;
|
|
}
|
|
|
|
.col-trackingNumber {
|
|
min-width: 200px;
|
|
width: 200px;
|
|
}
|
|
|
|
.col-revision {
|
|
width: 80px;
|
|
}
|
|
|
|
.col-status {
|
|
width: 100px;
|
|
}
|
|
|
|
.col-title {
|
|
min-width: 200px;
|
|
}
|
|
|
|
.col-sha256 {
|
|
min-width: 150px;
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.col-actions {
|
|
width: 100px;
|
|
text-align: center;
|
|
}
|
|
|
|
/* Row States */
|
|
.spreadsheet tbody tr {
|
|
transition: background-color 0.15s;
|
|
}
|
|
|
|
.spreadsheet tbody tr:hover {
|
|
background-color: var(--bg-hover);
|
|
}
|
|
|
|
.spreadsheet tbody tr.modified {
|
|
border-left: 3px solid var(--warning);
|
|
}
|
|
|
|
.spreadsheet tbody tr.error {
|
|
border-left: 3px solid var(--danger);
|
|
background-color: rgba(220, 53, 69, 0.08);
|
|
}
|
|
|
|
.spreadsheet tbody tr.saving {
|
|
opacity: 0.6;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Cell Content */
|
|
.cell-content {
|
|
display: block;
|
|
width: 100%;
|
|
}
|
|
|
|
.cell-link {
|
|
color: var(--primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.cell-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.cell-extension {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.cell-computed {
|
|
font-style: italic;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Editable Cells */
|
|
.cell-editable {
|
|
cursor: text;
|
|
position: relative;
|
|
}
|
|
|
|
.cell-editable:hover {
|
|
background-color: var(--bg-hover);
|
|
}
|
|
|
|
.cell-content[contenteditable="true"] {
|
|
outline: 2px solid var(--primary);
|
|
outline-offset: 0;
|
|
background-color: var(--bg);
|
|
min-height: 1.5em;
|
|
}
|
|
|
|
.cell-content[contenteditable="true"]:focus {
|
|
outline: 2px solid var(--primary);
|
|
outline-offset: 0;
|
|
}
|
|
|
|
.cell-content.editing {
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
/* Computed cells */
|
|
.cell-editable.computed {
|
|
font-style: italic;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.cell-editable.computed:hover {
|
|
font-style: normal;
|
|
color: var(--text);
|
|
}
|
|
|
|
/* Validation states */
|
|
.validation-error {
|
|
background-color: rgba(220, 53, 69, 0.1);
|
|
border-left: 3px solid var(--danger);
|
|
}
|
|
|
|
.validation-warning {
|
|
background-color: rgba(255, 193, 7, 0.1);
|
|
border-left: 3px solid #ffc107;
|
|
}
|
|
|
|
/* Inline Actions */
|
|
.inline-actions {
|
|
position: absolute;
|
|
right: 0.25rem;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
background-color: var(--bg);
|
|
padding: 0.125rem;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.cell-editable {
|
|
position: relative;
|
|
padding-right: 3.5rem; /* Space for buttons */
|
|
}
|
|
|
|
.btn-inline {
|
|
border: none;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 3px;
|
|
transition: all 0.15s;
|
|
font-weight: bold;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.btn-save {
|
|
color: var(--success);
|
|
}
|
|
|
|
.btn-save:hover {
|
|
background-color: var(--success);
|
|
color: white;
|
|
}
|
|
|
|
.btn-cancel {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.btn-cancel:hover {
|
|
background-color: var(--danger);
|
|
color: white;
|
|
}
|
|
|
|
/* Formula Preview */
|
|
.formula-preview {
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 0;
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 0.5rem;
|
|
font-size: 12px;
|
|
min-width: 200px;
|
|
z-index: 100;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.formula-preview-label {
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.formula-preview-value {
|
|
color: var(--text);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.formula-preview-value.valid {
|
|
color: var(--success);
|
|
}
|
|
|
|
.formula-preview-value.invalid {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.preview-check {
|
|
color: var(--success);
|
|
font-size: 16px;
|
|
cursor: pointer;
|
|
padding: 0.25rem;
|
|
border-radius: 3px;
|
|
transition: background-color 0.15s;
|
|
}
|
|
|
|
.preview-check:hover {
|
|
background-color: rgba(40, 167, 69, 0.1);
|
|
}
|
|
|
|
.preview-error {
|
|
color: var(--danger);
|
|
font-size: 16px;
|
|
}
|
|
|
|
.formula-preview-errors {
|
|
margin-top: 0.25rem;
|
|
font-size: 11px;
|
|
color: var(--danger);
|
|
white-space: normal;
|
|
}
|
|
|
|
/* Validation States */
|
|
.cell-warning {
|
|
background-color: rgba(255, 193, 7, 0.08);
|
|
border-left: 3px solid var(--warning);
|
|
}
|
|
|
|
.cell-error {
|
|
background-color: rgba(220, 53, 69, 0.08);
|
|
border-left: 3px solid var(--danger);
|
|
}
|
|
|
|
.validation-icon {
|
|
display: inline-block;
|
|
margin-left: 0.25rem;
|
|
cursor: help;
|
|
}
|
|
|
|
/* SHA256 Column */
|
|
.sha256-hash {
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.sha256-calculating {
|
|
font-style: italic;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Action Buttons */
|
|
.row-actions {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
justify-content: center;
|
|
}
|
|
|
|
.btn-icon {
|
|
width: 28px;
|
|
height: 28px;
|
|
padding: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
background-color: var(--bg);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.btn-icon:hover:not(:disabled) {
|
|
background-color: var(--bg-hover);
|
|
transform: scale(1.1);
|
|
}
|
|
|
|
.btn-icon:disabled {
|
|
opacity: 0.3;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-save {
|
|
color: var(--success);
|
|
}
|
|
|
|
.btn-cancel {
|
|
color: var(--danger);
|
|
}
|
|
|
|
/* Empty State */
|
|
.empty-state {
|
|
padding: 3rem;
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.empty-state h3 {
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
/* Spreadsheet Empty State */
|
|
.spreadsheet-empty {
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
/* Selection Highlight */
|
|
.cell-selected {
|
|
outline: 2px solid var(--primary);
|
|
outline-offset: -2px;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* Scrollbar Styling */
|
|
.spreadsheet-container::-webkit-scrollbar {
|
|
width: 12px;
|
|
height: 12px;
|
|
}
|
|
|
|
.spreadsheet-container::-webkit-scrollbar-track {
|
|
background-color: var(--bg-secondary);
|
|
}
|
|
|
|
.spreadsheet-container::-webkit-scrollbar-thumb {
|
|
background-color: var(--border-dark);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.spreadsheet-container::-webkit-scrollbar-thumb:hover {
|
|
background-color: var(--text-muted);
|
|
}
|
|
|
|
/* Preview button active state */
|
|
#togglePreviewBtn.preview-active {
|
|
background-color: var(--primary);
|
|
color: white;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<!-- Main Application -->
|
|
<div id="mainApp" class="main-app">
|
|
<!-- Header -->
|
|
<header class="app-header">
|
|
<div class="header-left">
|
|
<div class="header-title-group">
|
|
<span class="app-header__title">ZDDC Classifier</span>
|
|
<span class="build-timestamp">v0.0.1</span>
|
|
</div>
|
|
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
|
|
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>
|
|
</div>
|
|
<div class="header-right">
|
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
|
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main Content -->
|
|
<div class="main-content">
|
|
<!-- Folder Tree -->
|
|
<aside class="folder-tree-pane" id="folderTreePane">
|
|
<div class="pane-header">
|
|
<div class="pane-header-title">
|
|
<button class="btn btn-sm collapse-tree-btn" id="collapseTreeBtn" title="Collapse folder tree">◀</button>
|
|
<h3>Folder Tree</h3>
|
|
</div>
|
|
<div class="pane-header-controls">
|
|
<label class="checkbox-label" title="Auto-scroll folder tree when hovering files">
|
|
<input type="checkbox" id="autoScrollCheckbox" checked>
|
|
Auto-scroll
|
|
</label>
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="hideCompliantCheckbox">
|
|
Hide Compliant
|
|
</label>
|
|
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
|
|
</div>
|
|
</div>
|
|
<div id="folderTree" class="folder-tree">
|
|
<!-- Dynamically populated -->
|
|
</div>
|
|
<div class="resize-handle" id="treeResizeHandle"></div>
|
|
</aside>
|
|
|
|
<!-- Spreadsheet Table -->
|
|
<main class="spreadsheet-pane">
|
|
<div class="pane-header">
|
|
<div class="pane-header-left">
|
|
<h3>Files</h3>
|
|
<div class="file-stats">
|
|
<span id="totalFiles">0 files</span>
|
|
<span id="modifiedFiles">0 modified</span>
|
|
<span id="errorFiles" class="hidden">0 errors</span>
|
|
</div>
|
|
</div>
|
|
<div class="pane-header-right">
|
|
<button id="saveAllBtn" class="btn btn-success btn-sm" disabled>Save All</button>
|
|
<button id="cancelAllBtn" class="btn btn-secondary btn-sm" disabled>Cancel All</button>
|
|
<span class="header-divider">|</span>
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="sha256Checkbox">
|
|
SHA256
|
|
</label>
|
|
<button id="exportHashesBtn" class="btn btn-secondary btn-sm" disabled title="Export SHA256 hashes in sha256sum format">💾 Export Hashes</button>
|
|
<span class="header-divider">|</span>
|
|
<button id="togglePreviewBtn" class="btn btn-secondary btn-sm" title="Toggle file preview panel">👁 Preview</button>
|
|
</div>
|
|
</div>
|
|
<div class="spreadsheet-container">
|
|
<table id="spreadsheet" class="spreadsheet">
|
|
<thead>
|
|
<tr>
|
|
<th class="col-row-num">#</th>
|
|
<th class="col-original">Original Filename
|
|
<input type="text" class="column-filter" data-filter-field="original" placeholder="filter…" spellcheck="false" aria-label="Filter by original filename">
|
|
</th>
|
|
<th class="col-extension">Ext
|
|
<input type="text" class="column-filter" data-filter-field="extension" placeholder="filter…" spellcheck="false" aria-label="Filter by extension">
|
|
</th>
|
|
<th class="col-new">New Filename
|
|
<input type="text" class="column-filter" data-filter-field="newFilename" placeholder="filter…" spellcheck="false" aria-label="Filter by new filename">
|
|
</th>
|
|
<th class="col-trackingNumber">Tracking
|
|
<input type="text" class="column-filter" data-filter-field="trackingNumber" placeholder="filter…" spellcheck="false" aria-label="Filter by tracking number">
|
|
</th>
|
|
<th class="col-revision">Rev
|
|
<input type="text" class="column-filter" data-filter-field="revision" placeholder="filter…" spellcheck="false" aria-label="Filter by revision">
|
|
</th>
|
|
<th class="col-status">Status
|
|
<input type="text" class="column-filter" data-filter-field="status" placeholder="filter…" spellcheck="false" aria-label="Filter by status">
|
|
</th>
|
|
<th class="col-title">Title
|
|
<input type="text" class="column-filter" data-filter-field="title" placeholder="filter…" spellcheck="false" aria-label="Filter by title">
|
|
</th>
|
|
<th class="col-sha256 hidden" id="sha256Column">SHA256
|
|
<input type="text" class="column-filter" data-filter-field="sha256" placeholder="filter…" spellcheck="false" aria-label="Filter by SHA256">
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="spreadsheetBody">
|
|
<!-- Dynamically populated -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</main>
|
|
|
|
</div>
|
|
|
|
<!-- Empty State — shown until a directory is selected -->
|
|
<div id="welcomeScreen" class="empty-state">
|
|
<div class="empty-state-content">
|
|
<h2>ZDDC Classifier</h2>
|
|
<p>Rename a folder of files to ZDDC format using a spreadsheet interface.</p>
|
|
<p>Open a directory, fill in tracking number, revision, status, and title for each file, then save — the files are renamed on disk.</p>
|
|
|
|
<!-- Browser Compatibility Warning -->
|
|
<div id="browserWarning" class="browser-warning hidden">
|
|
<h3>⚠️ Browser Not Supported</h3>
|
|
<p>This application requires the File System Access API, available only in Chromium-based browsers (Chrome, Edge, Brave, Opera).</p>
|
|
</div>
|
|
|
|
<ul class="welcome-list">
|
|
<li>Files already named to ZDDC format are parsed automatically</li>
|
|
<li>Edit cells directly, or copy columns to and from Excel</li>
|
|
<li>Real-time validation highlights non-compliant names</li>
|
|
<li>Rename one file or all modified files at once</li>
|
|
</ul>
|
|
|
|
<p>Click <strong>Select Directory</strong> to begin.</p>
|
|
|
|
<p class="note">This application works entirely in your browser. No data is transmitted to any server.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Help Panel -->
|
|
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
|
<div class="help-panel__header">
|
|
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Classifier</h2>
|
|
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">×</button>
|
|
</div>
|
|
<div class="help-panel__body">
|
|
<h3>What is the Classifier?</h3>
|
|
<p>The Classifier is a spreadsheet-based tool for renaming files to ZDDC naming conventions. It reads a folder of files and presents them in an editable grid where you can set tracking number, revision, status, and title — then saves the renamed files back to disk.</p>
|
|
|
|
<h3>Getting Started</h3>
|
|
<ol>
|
|
<li>Click <strong>Select Directory</strong> to open a folder containing files to rename.</li>
|
|
<li>The folder tree on the left shows all sub-folders. Click a folder to load its files.</li>
|
|
<li>Edit cells in the spreadsheet to set the new filename components.</li>
|
|
<li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li>
|
|
</ol>
|
|
|
|
<h3>Folder Tree</h3>
|
|
<dl>
|
|
<dt>Multi-select</dt>
|
|
<dd>Hold <kbd>Ctrl</kbd> and click to select multiple folders. Hold <kbd>Shift</kbd> to select a range. Files from all selected folders are shown together.</dd>
|
|
<dt>Hide Compliant</dt>
|
|
<dd>Hides folders where all files already have valid ZDDC names, letting you focus on work remaining.</dd>
|
|
<dt>Auto-scroll</dt>
|
|
<dd>When enabled, the folder tree scrolls to highlight the folder containing the row you are editing.</dd>
|
|
</dl>
|
|
|
|
<h3>Spreadsheet Editing</h3>
|
|
<dl>
|
|
<dt>Direct cell editing</dt>
|
|
<dd>Click any cell in the New Filename, Tracking, Rev, Status, or Title columns to edit it. Press <kbd>Enter</kbd> to confirm, <kbd>Escape</kbd> to cancel.</dd>
|
|
<dt>RC References</dt>
|
|
<dd>Type a formula like <code>=R[-1]C</code> to copy the value from the cell one row above in the same column — similar to Excel relative references.</dd>
|
|
<dt>Regex capture groups</dt>
|
|
<dd>Type a formula like <code>=RE(RC[-3], "(\w+)-(\d+)", "$1")</code> to extract a pattern from another cell using a regular expression.</dd>
|
|
<dt>Validation</dt>
|
|
<dd>Cells are validated automatically. Invalid values are highlighted in red. The New Filename column shows the composed result.</dd>
|
|
<dt>Column Filters</dt>
|
|
<dd>Each column header has a filter input. Supported syntax:</dd>
|
|
</dl>
|
|
<dl>
|
|
<dt><code>term</code></dt>
|
|
<dd>Contains "term" (case-insensitive)</dd>
|
|
<dt><code>!term</code></dt>
|
|
<dd>Does not contain "term"</dd>
|
|
<dt><code>^term</code></dt>
|
|
<dd>Starts with "term"</dd>
|
|
<dt><code>term$</code></dt>
|
|
<dd>Ends with "term"</dd>
|
|
<dt><code>a b</code></dt>
|
|
<dd>Matches both (AND)</dd>
|
|
<dt><code>a | b</code></dt>
|
|
<dd>Matches either (OR)</dd>
|
|
<dt><code>^IFA | ^IFB</code></dt>
|
|
<dd>Starts with IFA or IFB</dd>
|
|
<dt><code>!^~</code></dt>
|
|
<dd>Does not start with ~ (excludes drafts)</dd>
|
|
<dt><code>el.*spc</code></dt>
|
|
<dd>Regex: contains "el" followed by "spc" (use <code>.</code> for any char, <code>.*</code> for any sequence)</dd>
|
|
<dt><code>[ei]fa</code></dt>
|
|
<dd>Regex character class: matches "efa" or "ifa"</dd>
|
|
</dl>
|
|
|
|
<h3>Saving Files</h3>
|
|
<dl>
|
|
<dt>Save All</dt>
|
|
<dd>Renames all modified files in one operation. Confirms before proceeding.</dd>
|
|
<dt>Cancel All</dt>
|
|
<dd>Reverts all unsaved edits back to the original filenames.</dd>
|
|
<dt>SHA256</dt>
|
|
<dd>Enable to compute a cryptographic hash of each file. Use <strong>Export Hashes</strong> to save a <code>sha256sum</code>-compatible file.</dd>
|
|
</dl>
|
|
|
|
<h3>ZDDC Filename Format</h3>
|
|
<p>The required format is:</p>
|
|
<p><code>TRACKINGNUMBER_REVISION (STATUS) - Title.ext</code></p>
|
|
<p>Example: <code>123456-EL-SPC-2623_A (IFR) - Electrical Specification.pdf</code></p>
|
|
<p>Valid statuses: IFA, IFB, IFC, IFD, IFI, IFP, IFR, IFU, REC, RSA, RSB, RSC, RSD, RSI, ---</p>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<script>
|
|
/**
|
|
* ZDDC — shared naming convention library
|
|
*
|
|
* Canonical implementation of all ZDDC filename, folder name, tracking number,
|
|
* revision, and status logic. Included in every tool's build via shared/zddc.js.
|
|
*
|
|
* Exposed as window.zddc (plain global) so it works with every tool's module
|
|
* pattern (archive globals, classifier IIFE, transmittal IIFE, mdedit globals).
|
|
*
|
|
* Public API
|
|
* ----------
|
|
* zddc.parseFilename(str) → ParsedFile | null
|
|
* zddc.parseFolder(str) → ParsedFolder | null
|
|
* zddc.parseRevision(str) → ParsedRevision
|
|
* zddc.formatFilename(parts) → string
|
|
* zddc.formatFolder(parts) → string
|
|
* zddc.compareRevisions(a, b) → number (-1 | 0 | 1)
|
|
* zddc.isValidStatus(str) → boolean
|
|
* zddc.STATUSES → string[]
|
|
*
|
|
* ParsedFile { trackingNumber, revision, status, title, extension }
|
|
* ParsedFolder { date, trackingNumber, status, title }
|
|
* ParsedRevision { base, modifier, modifierType, modifierNumber, isDraft, full }
|
|
*/
|
|
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
// ── Valid status codes ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Complete list of valid ZDDC document status codes.
|
|
* '---' denotes an unknown or not-yet-assigned status.
|
|
*/
|
|
var STATUSES = [
|
|
'---',
|
|
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
|
'REC',
|
|
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
|
];
|
|
|
|
var STATUS_SET = {};
|
|
for (var _i = 0; _i < STATUSES.length; _i++) {
|
|
STATUS_SET[STATUSES[_i]] = true;
|
|
}
|
|
|
|
function isValidStatus(str) {
|
|
return !!STATUS_SET[str];
|
|
}
|
|
|
|
// ── Filename parsing ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Canonical file regex.
|
|
* Matches: TRACKING_REVISION (STATUS) - TITLE.EXT
|
|
*
|
|
* Tracking number: no underscores, no whitespace.
|
|
* Revision: no whitespace, no parentheses.
|
|
* Status: anything inside parentheses (validated separately).
|
|
* Title: everything up to the last dot.
|
|
* Extension: after the last dot (lowercased by parseFilename).
|
|
*/
|
|
var FILE_RE = /^([^_\s]+)_([^\s()_]+)\s*\(([^)]+)\)\s*-\s*(\S.*\S|\S)\.\s*([^\s.]+)$/;
|
|
|
|
/**
|
|
* Parse a ZDDC filename.
|
|
*
|
|
* @param {string} filename
|
|
* @returns {{ trackingNumber: string, revision: string, status: string,
|
|
* title: string, extension: string, valid: boolean } | null}
|
|
* null only if filename is falsy.
|
|
* `valid` is true when all fields matched the ZDDC pattern.
|
|
*/
|
|
function parseFilename(filename) {
|
|
if (!filename) { return null; }
|
|
|
|
var match = filename.match(FILE_RE);
|
|
|
|
if (!match) {
|
|
var lastDot = filename.lastIndexOf('.');
|
|
return {
|
|
trackingNumber: '',
|
|
revision: '',
|
|
status: '',
|
|
title: lastDot > 0 ? filename.substring(0, lastDot) : filename,
|
|
extension: lastDot > 0 ? filename.substring(lastDot + 1).toLowerCase() : '',
|
|
valid: false,
|
|
};
|
|
}
|
|
|
|
return {
|
|
trackingNumber: match[1].trim(),
|
|
revision: match[2].trim(),
|
|
status: match[3].trim(),
|
|
title: match[4].trim(),
|
|
extension: match[5].toLowerCase(),
|
|
valid: true,
|
|
};
|
|
}
|
|
|
|
// ── Folder name parsing ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Transmittal folder regex.
|
|
* Matches: YYYY-MM-DD_TRACKING (STATUS) - TITLE
|
|
*/
|
|
var FOLDER_RE = /^(\d{4}-\d{2}-\d{2})_([^_\s(]+)\s*\(([^)]+)\)\s*-\s*(.+)$/;
|
|
|
|
/**
|
|
* Parse a ZDDC transmittal folder name.
|
|
*
|
|
* @param {string} foldername
|
|
* @returns {{ date: string, trackingNumber: string, status: string,
|
|
* title: string, valid: boolean } | null}
|
|
* null only if foldername is falsy.
|
|
*/
|
|
function parseFolder(foldername) {
|
|
if (!foldername) { return null; }
|
|
|
|
var match = foldername.match(FOLDER_RE);
|
|
|
|
if (!match) {
|
|
return {
|
|
date: '',
|
|
trackingNumber: '',
|
|
status: '',
|
|
title: foldername,
|
|
valid: false,
|
|
};
|
|
}
|
|
|
|
return {
|
|
date: match[1],
|
|
trackingNumber: match[2].trim(),
|
|
status: match[3].trim(),
|
|
title: match[4].trim(),
|
|
valid: true,
|
|
};
|
|
}
|
|
|
|
// ── Revision parsing ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Modifier sub-regex: +LETTER DIGITS e.g. +C1, +B2, +N1, +Q1
|
|
* The draft prefix (~) may appear inside the modifier: A+~C1
|
|
*/
|
|
var MODIFIER_RE = /^\+(~?)([A-Za-z])(\d+)$/;
|
|
|
|
/**
|
|
* Parse a ZDDC revision string.
|
|
*
|
|
* Revision grammar:
|
|
* revision = ['~'] base ['+' ['~'] modifier_letter modifier_number]
|
|
* base = letter(s) | digit(s) | date(YYYY-MM-DD)
|
|
* modifier = letter + digits e.g. C1, B2, N1, Q1
|
|
*
|
|
* @param {string} revision
|
|
* @returns {{
|
|
* base: string,
|
|
* modifier: string, full modifier string e.g. '+C1', '' if none
|
|
* modifierType: string, modifier letter e.g. 'C', '' if none
|
|
* modifierNumber: number, modifier number e.g. 1, 0 if none
|
|
* modifierIsDraft: boolean,
|
|
* isDraft: boolean, true if base revision starts with ~
|
|
* full: string, original input
|
|
* }}
|
|
*/
|
|
function parseRevision(revision) {
|
|
var raw = (revision || '').toString();
|
|
|
|
// Split on '+' to separate base from optional modifier
|
|
var plusIdx = raw.indexOf('+');
|
|
var basePart = plusIdx === -1 ? raw : raw.substring(0, plusIdx);
|
|
var modifierPart = plusIdx === -1 ? '' : raw.substring(plusIdx);
|
|
|
|
// Draft flag on the base part
|
|
var isDraft = basePart.startsWith('~');
|
|
var base = isDraft ? basePart.substring(1) : basePart;
|
|
|
|
// Parse modifier
|
|
var modifier = '';
|
|
var modifierType = '';
|
|
var modifierNumber = 0;
|
|
var modifierIsDraft = false;
|
|
|
|
if (modifierPart) {
|
|
var mMatch = modifierPart.match(MODIFIER_RE);
|
|
if (mMatch) {
|
|
modifierIsDraft = mMatch[1] === '~';
|
|
modifierType = mMatch[2].toUpperCase();
|
|
modifierNumber = parseInt(mMatch[3], 10);
|
|
modifier = modifierPart;
|
|
} else {
|
|
// Unrecognised modifier — preserve as-is
|
|
modifier = modifierPart;
|
|
}
|
|
}
|
|
|
|
return {
|
|
base: base,
|
|
modifier: modifier,
|
|
modifierType: modifierType,
|
|
modifierNumber: modifierNumber,
|
|
modifierIsDraft: modifierIsDraft,
|
|
isDraft: isDraft,
|
|
full: raw,
|
|
};
|
|
}
|
|
|
|
// ── Revision comparison ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Classify a base revision string into a sort tier:
|
|
* 0 = date (YYYY-MM-DD)
|
|
* 1 = letter(s) A, B, AA …
|
|
* 2 = number(s) 0, 1, 2, 1.5 …
|
|
* 3 = other
|
|
*/
|
|
function _baseTier(base) {
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(base)) { return 0; }
|
|
if (/^[A-Za-z]+$/.test(base)) { return 1; }
|
|
if (/^\d+(\.\d+)?$/.test(base)) { return 2; }
|
|
return 3;
|
|
}
|
|
|
|
/**
|
|
* Compare two base revision strings.
|
|
* Sort order: dates < letters < numbers < other.
|
|
*/
|
|
function _compareBase(a, b) {
|
|
var ta = _baseTier(a);
|
|
var tb = _baseTier(b);
|
|
if (ta !== tb) { return ta - tb; }
|
|
|
|
if (ta === 0) { return a < b ? -1 : a > b ? 1 : 0; } // date lexicographic = chronological
|
|
if (ta === 1) { return a.toUpperCase() < b.toUpperCase() ? -1 : a.toUpperCase() > b.toUpperCase() ? 1 : 0; }
|
|
if (ta === 2) { return parseFloat(a) - parseFloat(b); }
|
|
return a.localeCompare(b);
|
|
}
|
|
|
|
/**
|
|
* Compare two ZDDC revision strings for sort ordering.
|
|
*
|
|
* Canonical order (ascending = older → newer):
|
|
* ~A < A < A+B1 < A+C1 < A+~C2 < A+C2 < A+N1 < A+Q1
|
|
* < ~B < B < … < 0 < 1 < 2
|
|
*
|
|
* Rules:
|
|
* 1. Compare base revisions first (dates < letters < numbers).
|
|
* 2. For equal bases, draft (isDraft=true) comes before final.
|
|
* 3. For equal base+draft, no-modifier < has-modifier.
|
|
* 4. For equal base+draft+modifier presence:
|
|
* a. modifier draft comes before modifier final (modifierIsDraft).
|
|
* b. Sort modifier by type letter then by number.
|
|
*
|
|
* @param {string} a
|
|
* @param {string} b
|
|
* @returns {number} negative if a < b, 0 if equal, positive if a > b
|
|
*/
|
|
function compareRevisions(a, b) {
|
|
var pa = parseRevision(a);
|
|
var pb = parseRevision(b);
|
|
|
|
// 1. Base revision
|
|
var baseCmp = _compareBase(pa.base, pb.base);
|
|
if (baseCmp !== 0) { return baseCmp; }
|
|
|
|
// 2. Draft before final (for same base)
|
|
if (pa.isDraft !== pb.isDraft) { return pa.isDraft ? -1 : 1; }
|
|
|
|
// 3. No modifier before any modifier
|
|
var aHasMod = pa.modifier !== '';
|
|
var bHasMod = pb.modifier !== '';
|
|
if (aHasMod !== bHasMod) { return aHasMod ? 1 : -1; }
|
|
|
|
if (!aHasMod) { return 0; } // both have no modifier
|
|
|
|
// 4. Compare modifiers: type → number → draft (draft is a tie-breaker only)
|
|
// 4a. Modifier type letter (B < C < N < Q …)
|
|
if (pa.modifierType !== pb.modifierType) {
|
|
return pa.modifierType < pb.modifierType ? -1 : 1;
|
|
}
|
|
|
|
// 4b. Modifier number (1 < 2 …)
|
|
if (pa.modifierNumber !== pb.modifierNumber) {
|
|
return pa.modifierNumber - pb.modifierNumber;
|
|
}
|
|
|
|
// 4c. Draft of a modifier comes before the final modifier (same type+number)
|
|
if (pa.modifierIsDraft !== pb.modifierIsDraft) {
|
|
return pa.modifierIsDraft ? -1 : 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
// ── Filename / folder formatting ─────────────────────────────────────────
|
|
|
|
/**
|
|
* Build a ZDDC filename from its components.
|
|
*
|
|
* @param {{ trackingNumber: string, revision: string, status: string,
|
|
* title: string, extension: string }} parts
|
|
* @returns {string} e.g. "123456-EL-SPC-2623_A (IFR) - Specification.pdf"
|
|
*/
|
|
function formatFilename(parts) {
|
|
var tn = (parts.trackingNumber || '').trim();
|
|
var rev = (parts.revision || '').trim();
|
|
var st = (parts.status || '').trim();
|
|
var ttl = (parts.title || '').trim();
|
|
var ext = (parts.extension || '').replace(/^\./, '');
|
|
|
|
if (!tn || !rev || !st || !ttl) { return ''; }
|
|
|
|
var name = tn + '_' + rev + ' (' + st + ') - ' + ttl;
|
|
return ext ? name + '.' + ext : name;
|
|
}
|
|
|
|
/**
|
|
* Build a ZDDC transmittal folder name from its components.
|
|
*
|
|
* @param {{ date: string, trackingNumber: string, status: string,
|
|
* title: string }} parts
|
|
* @returns {string} e.g. "2025-10-31_123456-EM-SUB-0001 (IFR) - Title"
|
|
*/
|
|
function formatFolder(parts) {
|
|
var dt = (parts.date || '').trim();
|
|
var tn = (parts.trackingNumber || '').trim();
|
|
var st = (parts.status || '').trim();
|
|
var ttl = (parts.title || '').trim();
|
|
|
|
if (!dt || !tn || !st || !ttl) { return ''; }
|
|
|
|
return dt + '_' + tn + ' (' + st + ') - ' + ttl;
|
|
}
|
|
|
|
// ── Filename / extension splitting ───────────────────────────────────────
|
|
|
|
/**
|
|
* Split a filename into its base name and extension (no leading dot).
|
|
* Treats leading dot ('.gitignore') as no extension.
|
|
*
|
|
* @param {string} filename
|
|
* @returns {{ name: string, extension: string }}
|
|
*/
|
|
function splitExtension(filename) {
|
|
if (!filename) { return { name: '', extension: '' }; }
|
|
var lastDot = filename.lastIndexOf('.');
|
|
if (lastDot <= 0) { return { name: filename, extension: '' }; }
|
|
return {
|
|
name: filename.substring(0, lastDot),
|
|
extension: filename.substring(lastDot + 1).toLowerCase(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Join a base name and extension. Tolerant of either form ('pdf' or '.pdf').
|
|
* Returns just the name when extension is empty.
|
|
*/
|
|
function joinExtension(name, extension) {
|
|
var ext = (extension || '').replace(/^\./, '');
|
|
return ext ? name + '.' + ext : name;
|
|
}
|
|
|
|
// ── Public API ───────────────────────────────────────────────────────────
|
|
|
|
root.zddc = {
|
|
STATUSES: STATUSES,
|
|
isValidStatus: isValidStatus,
|
|
parseFilename: parseFilename,
|
|
parseFolder: parseFolder,
|
|
parseRevision: parseRevision,
|
|
formatFilename: formatFilename,
|
|
formatFolder: formatFolder,
|
|
compareRevisions: compareRevisions,
|
|
splitExtension: splitExtension,
|
|
joinExtension: joinExtension,
|
|
};
|
|
|
|
}(typeof window !== 'undefined' ? window : this));
|
|
|
|
/**
|
|
* ZDDC — shared SHA-256 helpers
|
|
*
|
|
* Attaches to window.zddc.crypto. Must load AFTER shared/zddc.js (which creates
|
|
* the window.zddc object).
|
|
*
|
|
* Exports:
|
|
* zddc.crypto.sha256Hex(buffer) → Promise<string> hex digest of ArrayBuffer/Uint8Array
|
|
* zddc.crypto.sha256String(str) → Promise<string> hex digest of UTF-8 encoded string
|
|
* zddc.crypto.sha256File(file, onProgress?) → Promise<string>
|
|
* chunked streaming digest for File/Blob; for files >= 4 MB, streams 2 MB chunks
|
|
* and invokes onProgress(loaded, total) every ~8 MB.
|
|
* zddc.crypto.bytesToHex(buffer) → string (hex of ArrayBuffer/Uint8Array, no digest)
|
|
*
|
|
* Throws if Web Crypto SubtleCrypto is not available.
|
|
*/
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
if (!root.zddc) {
|
|
throw new Error('shared/hash.js: window.zddc must be loaded first');
|
|
}
|
|
|
|
var HASH_CHUNK_SIZE = 2 * 1024 * 1024; // 2 MB
|
|
|
|
function requireSubtle() {
|
|
if (!root.crypto || !root.crypto.subtle || typeof root.crypto.subtle.digest !== 'function') {
|
|
throw new Error('Web Crypto SubtleCrypto is required');
|
|
}
|
|
}
|
|
|
|
function bytesToHex(buffer) {
|
|
return Array.from(new Uint8Array(buffer), function (byte) {
|
|
return byte.toString(16).padStart(2, '0');
|
|
}).join('');
|
|
}
|
|
|
|
async function sha256Hex(buffer) {
|
|
requireSubtle();
|
|
var input = (buffer instanceof Uint8Array) ? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) : buffer;
|
|
var hash = await root.crypto.subtle.digest('SHA-256', input);
|
|
return bytesToHex(hash);
|
|
}
|
|
|
|
async function sha256String(str) {
|
|
requireSubtle();
|
|
var bytes = new TextEncoder().encode(str);
|
|
var hash = await root.crypto.subtle.digest('SHA-256', bytes);
|
|
return bytesToHex(hash);
|
|
}
|
|
|
|
async function sha256File(file, onProgress) {
|
|
requireSubtle();
|
|
// Single-shot for small files or environments without ReadableStream
|
|
if (file.size < HASH_CHUNK_SIZE * 2 || typeof file.stream !== 'function') {
|
|
if (onProgress) { onProgress(file.size, file.size); }
|
|
var buf = await file.arrayBuffer();
|
|
var hash = await root.crypto.subtle.digest('SHA-256', buf);
|
|
return bytesToHex(hash);
|
|
}
|
|
// Chunked streaming for large files
|
|
var reader = file.stream().getReader();
|
|
var loaded = 0;
|
|
var chunks = [];
|
|
var yieldCounter = 0;
|
|
while (true) {
|
|
var result = await reader.read();
|
|
if (result.done) { break; }
|
|
chunks.push(result.value);
|
|
loaded += result.value.byteLength;
|
|
yieldCounter++;
|
|
if (onProgress && yieldCounter % 4 === 0) {
|
|
onProgress(loaded, file.size);
|
|
await new Promise(function (r) { setTimeout(r, 0); });
|
|
}
|
|
}
|
|
var total = new Uint8Array(loaded);
|
|
var offset = 0;
|
|
for (var i = 0; i < chunks.length; i++) {
|
|
total.set(chunks[i], offset);
|
|
offset += chunks[i].byteLength;
|
|
}
|
|
var digest = await root.crypto.subtle.digest('SHA-256', total.buffer);
|
|
if (onProgress) { onProgress(file.size, file.size); }
|
|
return bytesToHex(digest);
|
|
}
|
|
|
|
root.zddc.crypto = {
|
|
sha256Hex: sha256Hex,
|
|
sha256String: sha256String,
|
|
sha256File: sha256File,
|
|
bytesToHex: bytesToHex,
|
|
};
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|
|
|
|
/**
|
|
* ZDDC shared theme toggle — light / dark / auto.
|
|
* Persists choice to localStorage under 'zddc-theme'.
|
|
* Works with all four tools regardless of their module pattern.
|
|
* Expects: #theme-btn in the DOM (optional — skips gracefully if absent).
|
|
*
|
|
* Theme cycle: auto → light → dark → auto …
|
|
* 'auto' honours the OS prefers-color-scheme media query (CSS handles it).
|
|
* 'light' sets data-theme="light" on <html> (overrides dark media query).
|
|
* 'dark' sets data-theme="dark" on <html>.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var STORAGE_KEY = 'zddc-theme';
|
|
var THEMES = ['auto', 'light', 'dark'];
|
|
|
|
var LABELS = {
|
|
auto: '◐',
|
|
light: '☀',
|
|
dark: '☾'
|
|
};
|
|
|
|
var TITLES = {
|
|
auto: 'Theme: auto (follows OS)',
|
|
light: 'Theme: light',
|
|
dark: 'Theme: dark'
|
|
};
|
|
|
|
function load() {
|
|
var stored = localStorage.getItem(STORAGE_KEY);
|
|
return THEMES.indexOf(stored) !== -1 ? stored : 'auto';
|
|
}
|
|
|
|
function apply(theme) {
|
|
if (theme === 'dark') {
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
} else if (theme === 'light') {
|
|
document.documentElement.setAttribute('data-theme', 'light');
|
|
} else {
|
|
document.documentElement.removeAttribute('data-theme');
|
|
}
|
|
}
|
|
|
|
function save(theme) {
|
|
try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
|
|
}
|
|
|
|
function updateButton(btn, theme) {
|
|
btn.textContent = LABELS[theme];
|
|
btn.title = TITLES[theme];
|
|
btn.setAttribute('aria-label', TITLES[theme]);
|
|
}
|
|
|
|
function next(theme) {
|
|
return THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
|
|
}
|
|
|
|
function init() {
|
|
var current = load();
|
|
apply(current);
|
|
|
|
var btn = document.getElementById('theme-btn');
|
|
if (!btn) { return; }
|
|
|
|
updateButton(btn, current);
|
|
|
|
btn.addEventListener('click', function () {
|
|
current = next(current);
|
|
apply(current);
|
|
save(current);
|
|
updateButton(btn, current);
|
|
});
|
|
}
|
|
|
|
/* Apply theme immediately (before DOM ready) to avoid flash */
|
|
apply(load());
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
}());
|
|
|
|
/**
|
|
* ZDDC Classifier - Main Application
|
|
* Spreadsheet-based file renaming with Excel-like formulas
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Global application state
|
|
window.app = {
|
|
// File System
|
|
rootHandle: null,
|
|
|
|
// Data
|
|
folderTree: [],
|
|
selectedFolders: new Set(), // Multi-select support
|
|
lastSelectedFolderPath: null,
|
|
hideCompliant: false,
|
|
calculateSha256: false,
|
|
|
|
// DOM elements (populated on init)
|
|
dom: {},
|
|
|
|
// Modules (populated by other files)
|
|
modules: {}
|
|
};
|
|
|
|
/**
|
|
* Initialize the application
|
|
*/
|
|
function init() {
|
|
|
|
|
|
// Check browser compatibility
|
|
if (!checkBrowserCompatibility()) {
|
|
showBrowserWarning();
|
|
return;
|
|
}
|
|
|
|
// Cache DOM elements
|
|
cacheDOMElements();
|
|
|
|
// Set up event listeners
|
|
setupEventListeners();
|
|
|
|
// Show welcome screen
|
|
showWelcomeScreen();
|
|
|
|
|
|
}
|
|
|
|
/**
|
|
* Check if browser supports File System Access API
|
|
*/
|
|
function checkBrowserCompatibility() {
|
|
return 'showDirectoryPicker' in window;
|
|
}
|
|
|
|
/**
|
|
* Show browser compatibility warning
|
|
*/
|
|
function showBrowserWarning() {
|
|
const warning = document.getElementById('browserWarning');
|
|
const selectBtn = document.getElementById('selectDirectoryBtn');
|
|
if (warning) {
|
|
warning.classList.remove('hidden');
|
|
}
|
|
if (selectBtn) {
|
|
selectBtn.disabled = true;
|
|
selectBtn.textContent = 'Browser Not Supported';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cache DOM element references
|
|
*/
|
|
function cacheDOMElements() {
|
|
app.dom = {
|
|
// Screens
|
|
welcomeScreen: document.getElementById('welcomeScreen'),
|
|
mainApp: document.getElementById('mainApp'),
|
|
|
|
// Header buttons
|
|
selectDirectoryBtn: document.getElementById('selectDirectoryBtn'),
|
|
refreshBtn: document.getElementById('refreshBtn'),
|
|
saveAllBtn: document.getElementById('saveAllBtn'),
|
|
cancelAllBtn: document.getElementById('cancelAllBtn'),
|
|
exportHashesBtn: document.getElementById('exportHashesBtn'),
|
|
sha256Checkbox: document.getElementById('sha256Checkbox'),
|
|
hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'),
|
|
|
|
// Folder tree
|
|
folderTree: document.getElementById('folderTree'),
|
|
folderTreePane: document.getElementById('folderTreePane'),
|
|
collapseTreeBtn: document.getElementById('collapseTreeBtn'),
|
|
autoScrollCheckbox: document.getElementById('autoScrollCheckbox'),
|
|
selectedFoldersCount: document.getElementById('selectedFoldersCount'),
|
|
|
|
// Spreadsheet
|
|
spreadsheet: document.getElementById('spreadsheet'),
|
|
spreadsheetBody: document.getElementById('spreadsheetBody'),
|
|
sha256Column: document.getElementById('sha256Column'),
|
|
|
|
// Stats
|
|
totalFiles: document.getElementById('totalFiles'),
|
|
modifiedFiles: document.getElementById('modifiedFiles'),
|
|
errorFiles: document.getElementById('errorFiles'),
|
|
|
|
// Preview
|
|
togglePreviewBtn: document.getElementById('togglePreviewBtn')
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set up event listeners
|
|
*/
|
|
function setupEventListeners() {
|
|
// Directory selection
|
|
app.dom.selectDirectoryBtn.addEventListener('click', handleSelectDirectory);
|
|
app.dom.refreshBtn.addEventListener('click', handleRefresh);
|
|
|
|
// Drag and drop on welcome screen
|
|
setupWelcomeDragDrop();
|
|
|
|
// Bulk actions
|
|
app.dom.saveAllBtn.addEventListener('click', handleSaveAll);
|
|
app.dom.cancelAllBtn.addEventListener('click', handleCancelAll);
|
|
|
|
// Export hashes
|
|
app.dom.exportHashesBtn.addEventListener('click', handleExportHashes);
|
|
|
|
// SHA256 toggle
|
|
app.dom.sha256Checkbox.addEventListener('change', handleSha256Toggle);
|
|
|
|
// Hide compliant toggle
|
|
app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
|
|
|
|
// Collapse tree button
|
|
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
|
|
// Resize handle
|
|
setupResizeHandle();
|
|
}
|
|
|
|
/**
|
|
* Handle collapse/expand folder tree pane
|
|
*/
|
|
function handleCollapseTree() {
|
|
const pane = app.dom.folderTreePane;
|
|
const btn = app.dom.collapseTreeBtn;
|
|
|
|
pane.classList.toggle('collapsed');
|
|
|
|
if (pane.classList.contains('collapsed')) {
|
|
// Clear any inline width from resize handle
|
|
pane.style.width = '';
|
|
btn.textContent = '▶';
|
|
btn.title = 'Expand folder tree';
|
|
} else {
|
|
btn.textContent = '◀';
|
|
btn.title = 'Collapse folder tree';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up folder tree resize handle
|
|
*/
|
|
function setupResizeHandle() {
|
|
const handle = document.getElementById('treeResizeHandle');
|
|
const pane = document.getElementById('folderTreePane');
|
|
|
|
if (!handle || !pane) return;
|
|
|
|
let isResizing = false;
|
|
let startX = 0;
|
|
let startWidth = 0;
|
|
|
|
handle.addEventListener('mousedown', (e) => {
|
|
isResizing = true;
|
|
startX = e.clientX;
|
|
startWidth = pane.offsetWidth;
|
|
document.body.style.cursor = 'col-resize';
|
|
e.preventDefault();
|
|
});
|
|
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!isResizing) return;
|
|
|
|
const delta = e.clientX - startX;
|
|
const newWidth = startWidth + delta;
|
|
|
|
// Respect min width only
|
|
if (newWidth >= 150) {
|
|
pane.style.width = newWidth + 'px';
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mouseup', () => {
|
|
if (isResizing) {
|
|
isResizing = false;
|
|
document.body.style.cursor = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set up drag-and-drop on the welcome screen
|
|
*/
|
|
function setupWelcomeDragDrop() {
|
|
const screen = app.dom.welcomeScreen;
|
|
if (!screen) return;
|
|
|
|
['dragenter', 'dragover'].forEach(evt => {
|
|
screen.addEventListener(evt, (e) => {
|
|
e.preventDefault();
|
|
screen.classList.add('drag-over');
|
|
});
|
|
});
|
|
|
|
['dragleave', 'drop'].forEach(evt => {
|
|
screen.addEventListener(evt, (e) => {
|
|
e.preventDefault();
|
|
screen.classList.remove('drag-over');
|
|
});
|
|
});
|
|
|
|
screen.addEventListener('drop', async (e) => {
|
|
const item = e.dataTransfer.items && e.dataTransfer.items[0];
|
|
if (!item) return;
|
|
|
|
const handle = await item.getAsFileSystemHandle();
|
|
if (!handle || handle.kind !== 'directory') {
|
|
alert('Please drop a folder, not a file.');
|
|
return;
|
|
}
|
|
|
|
await openDirectory(handle);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle directory selection via button click
|
|
*/
|
|
async function handleSelectDirectory() {
|
|
try {
|
|
const dirHandle = await window.showDirectoryPicker();
|
|
await openDirectory(dirHandle);
|
|
} catch (err) {
|
|
if (err.name !== 'AbortError') {
|
|
console.error('Error selecting directory:', err);
|
|
alert('Error selecting directory: ' + err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a directory handle and initialize the application
|
|
*/
|
|
async function openDirectory(dirHandle) {
|
|
app.rootHandle = dirHandle;
|
|
|
|
// Hide welcome screen and show main UI
|
|
hideWelcomeScreen();
|
|
showMainUI();
|
|
|
|
// Initialize modules BEFORE scanning (so they're ready for store updates)
|
|
app.modules.spreadsheet.init(); // Subscribe to store
|
|
app.modules.selection.init();
|
|
app.modules.preview.init(); // After selection so it can listen for rowfocused
|
|
app.modules.resize.init();
|
|
app.modules.filter.init();
|
|
app.modules.sort.init();
|
|
app.modules.tree.setupKeyboardShortcuts();
|
|
|
|
// Now scan directory (this will trigger store updates and renders)
|
|
await app.modules.scanner.scanDirectory(dirHandle);
|
|
|
|
// Show refresh button now that a directory is loaded
|
|
if (app.dom.refreshBtn) { app.dom.refreshBtn.classList.remove('hidden'); }
|
|
}
|
|
|
|
/**
|
|
* Handle Refresh button - rescan current directory
|
|
*/
|
|
async function handleRefresh() {
|
|
if (!app.rootHandle) {
|
|
alert('No directory selected');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Clear current data
|
|
app.folderTree = [];
|
|
app.selectedFolders.clear();
|
|
app.lastSelectedFolderPath = null;
|
|
|
|
// Reset store
|
|
app.modules.store.reset();
|
|
|
|
// Rescan directory (modules already initialized, just rescan)
|
|
await app.modules.scanner.scanDirectory(app.rootHandle);
|
|
|
|
} catch (err) {
|
|
console.error('Error refreshing directory:', err);
|
|
alert('Error refreshing directory: ' + err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Save All button
|
|
*/
|
|
async function handleSaveAll() {
|
|
if (!confirm('Save all modified files?')) return;
|
|
|
|
try {
|
|
app.dom.saveAllBtn.disabled = true;
|
|
await app.modules.spreadsheet.saveAllFiles();
|
|
} catch (err) {
|
|
console.error('Error saving files:', err);
|
|
alert('Error saving files: ' + err.message);
|
|
} finally {
|
|
app.dom.saveAllBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Cancel All button
|
|
*/
|
|
function handleCancelAll() {
|
|
if (!confirm('Cancel all changes?')) return;
|
|
app.modules.spreadsheet.cancelAllChanges();
|
|
}
|
|
|
|
/**
|
|
* Handle Export Hashes button
|
|
*/
|
|
function handleExportHashes() {
|
|
app.modules.excel.exportHashes();
|
|
}
|
|
|
|
/**
|
|
* Handle SHA256 checkbox toggle
|
|
*/
|
|
function handleSha256Toggle() {
|
|
app.calculateSha256 = app.dom.sha256Checkbox.checked;
|
|
|
|
// Show/hide SHA256 column
|
|
if (app.calculateSha256) {
|
|
app.dom.sha256Column.classList.remove('hidden');
|
|
} else {
|
|
app.dom.sha256Column.classList.add('hidden');
|
|
}
|
|
|
|
// Re-render table
|
|
app.modules.spreadsheet.render();
|
|
}
|
|
|
|
/**
|
|
* Handle Hide Compliant checkbox toggle
|
|
*/
|
|
function handleHideCompliantToggle() {
|
|
app.hideCompliant = app.dom.hideCompliantCheckbox.checked;
|
|
app.modules.store.setHideCompliant(app.hideCompliant);
|
|
}
|
|
|
|
/**
|
|
* Handle keyboard shortcuts
|
|
*/
|
|
function handleKeyDown(e) {
|
|
// Ctrl+S - Save All
|
|
if (e.ctrlKey && e.key === 's') {
|
|
e.preventDefault();
|
|
if (!app.dom.saveAllBtn.disabled) {
|
|
handleSaveAll();
|
|
}
|
|
}
|
|
|
|
// Escape - Cancel editing
|
|
if (e.key === 'Escape') {
|
|
app.modules.spreadsheet.cancelEditing();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show welcome screen (empty-state overlay)
|
|
*/
|
|
function showWelcomeScreen() {
|
|
if (app.dom.welcomeScreen) {
|
|
app.dom.welcomeScreen.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hide welcome screen (empty-state overlay)
|
|
*/
|
|
function hideWelcomeScreen() {
|
|
if (app.dom.welcomeScreen) {
|
|
app.dom.welcomeScreen.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show main UI (no-op: main UI is always rendered)
|
|
*/
|
|
function showMainUI() {
|
|
// Main app is always visible; only the empty-state overlay is toggled
|
|
}
|
|
|
|
/**
|
|
* Update stats display
|
|
*/
|
|
function updateStats() {
|
|
const files = app.modules.store.getDisplayFiles();
|
|
const totalFiles = files.length;
|
|
const modifiedFiles = files.filter(f => f.isDirty).length;
|
|
const errorFiles = files.filter(f => f.error).length;
|
|
|
|
app.dom.totalFiles.textContent = `${totalFiles} file${totalFiles !== 1 ? 's' : ''}`;
|
|
app.dom.modifiedFiles.textContent = `${modifiedFiles} modified`;
|
|
|
|
if (errorFiles > 0) {
|
|
app.dom.errorFiles.textContent = `${errorFiles} error${errorFiles !== 1 ? 's' : ''}`;
|
|
app.dom.errorFiles.classList.remove('hidden');
|
|
} else {
|
|
app.dom.errorFiles.classList.add('hidden');
|
|
}
|
|
|
|
// Enable/disable bulk action buttons
|
|
app.dom.saveAllBtn.disabled = modifiedFiles === 0;
|
|
app.dom.cancelAllBtn.disabled = modifiedFiles === 0;
|
|
|
|
// Enable/disable export hashes button
|
|
app.dom.exportHashesBtn.disabled = totalFiles === 0 || !app.calculateSha256;
|
|
}
|
|
|
|
// Export functions for use by other modules
|
|
app.modules.app = {
|
|
updateStats
|
|
};
|
|
|
|
// Initialize when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|
|
|
|
/**
|
|
* Classifier utilities — thin convenience layer over window.zddc.
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
/**
|
|
* Compute new filename from file fields.
|
|
* ZDDC format: trackingNumber_revision (status) - title.ext
|
|
* Falls back to original filename if any required ZDDC field is missing.
|
|
*/
|
|
function computeNewFilename(file) {
|
|
if (file.manualFilename) {
|
|
return file.manualFilename;
|
|
}
|
|
|
|
const formatted = zddc.formatFilename({
|
|
trackingNumber: file.trackingNumber || '',
|
|
revision: file.revision || '',
|
|
status: file.status || '',
|
|
title: file.title || '',
|
|
extension: file.extension || '',
|
|
});
|
|
|
|
return formatted || zddc.joinExtension(file.originalFilename, file.extension);
|
|
}
|
|
|
|
/**
|
|
* Get column value from file object.
|
|
*/
|
|
function getColumnValue(file, columnName) {
|
|
switch (columnName) {
|
|
case 'original': return file.originalFilename || '';
|
|
case 'extension': return file.extension || '';
|
|
case 'new':
|
|
case 'newFilename': return file.manualFilename || computeNewFilename(file);
|
|
case 'trackingNumber': return file.trackingNumber || '';
|
|
case 'revision': return file.revision || '';
|
|
case 'status': return file.status || '';
|
|
case 'title': return file.title || '';
|
|
case 'sha256': return file.sha256 || '';
|
|
default: return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get MIME type from file extension (no leading dot).
|
|
*/
|
|
const MIME_TYPES = {
|
|
pdf: 'application/pdf',
|
|
jpg: 'image/jpeg',
|
|
jpeg: 'image/jpeg',
|
|
png: 'image/png',
|
|
gif: 'image/gif',
|
|
svg: 'image/svg+xml',
|
|
webp: 'image/webp',
|
|
bmp: 'image/bmp',
|
|
ico: 'image/x-icon',
|
|
txt: 'text/plain',
|
|
md: 'text/markdown',
|
|
json: 'application/json',
|
|
xml: 'application/xml',
|
|
csv: 'text/csv',
|
|
html: 'text/html',
|
|
css: 'text/css',
|
|
js: 'text/javascript',
|
|
};
|
|
|
|
function getMimeType(extension) {
|
|
return MIME_TYPES[(extension || '').toLowerCase()] || 'application/octet-stream';
|
|
}
|
|
|
|
window.app.modules.utils = {
|
|
computeNewFilename,
|
|
getColumnValue,
|
|
getMimeType,
|
|
};
|
|
})();
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Escape a string for use in a RegExp (literal match)
|
|
function escapeRegex(str) {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
// Build regex pattern at parse time based on anchors
|
|
function compilePattern(raw, anchorStart, anchorEnd) {
|
|
var src = (anchorStart ? '^' : '') + raw + (anchorEnd ? '$' : '');
|
|
try {
|
|
return new RegExp(src, 'i');
|
|
} catch (e) {
|
|
// Invalid regex — escape and retry (always succeeds)
|
|
var safe = (anchorStart ? '^' : '') + escapeRegex(raw) + (anchorEnd ? '$' : '');
|
|
return new RegExp(safe, 'i');
|
|
}
|
|
}
|
|
|
|
// Parse a single token string into a node
|
|
function parseToken(token) {
|
|
var s = token;
|
|
var negate = false;
|
|
var anchorStart = false;
|
|
var anchorEnd = false;
|
|
|
|
if (s.charAt(0) === '!') {
|
|
negate = true;
|
|
s = s.slice(1);
|
|
}
|
|
if (s.charAt(0) === '^') {
|
|
anchorStart = true;
|
|
s = s.slice(1);
|
|
}
|
|
if (s.length > 0 && s.charAt(s.length - 1) === '$') {
|
|
anchorEnd = true;
|
|
s = s.slice(0, -1);
|
|
}
|
|
|
|
if (s === '') return null;
|
|
|
|
// bare * (possibly after stripping !) → wildcard-all or wildcard-none
|
|
if (s === '*' && !anchorStart && !anchorEnd) {
|
|
return negate ? null : { type: 'wildcard-all' };
|
|
}
|
|
|
|
var re = compilePattern(s, anchorStart, anchorEnd);
|
|
return { type: negate ? 'no-match' : 'match', re: re };
|
|
}
|
|
|
|
// Parse expression string into AST array
|
|
function parse(expression) {
|
|
if (!expression || typeof expression !== 'string') return [];
|
|
var trimmed = expression.trim();
|
|
if (trimmed === '') return [];
|
|
if (trimmed === '*') return [{ type: 'wildcard-all' }];
|
|
|
|
var ast = [];
|
|
var i = 0;
|
|
var len = trimmed.length;
|
|
|
|
while (i < len) {
|
|
var ch = trimmed.charAt(i);
|
|
|
|
if (ch === '(') {
|
|
var depth = 1;
|
|
var j = i + 1;
|
|
while (j < len && depth > 0) {
|
|
if (trimmed.charAt(j) === '(') depth++;
|
|
else if (trimmed.charAt(j) === ')') depth--;
|
|
j++;
|
|
}
|
|
var innerAst = parse(trimmed.slice(i + 1, j - 1));
|
|
if (innerAst.length === 1) {
|
|
ast.push(innerAst[0]);
|
|
} else if (innerAst.length > 1) {
|
|
for (var k = 0; k < innerAst.length; k++) ast.push(innerAst[k]);
|
|
}
|
|
i = j;
|
|
} else if (ch === '|') {
|
|
ast.push({ type: 'pipe' });
|
|
i++;
|
|
} else if (ch === ' ') {
|
|
i++;
|
|
} else {
|
|
var j = i;
|
|
while (j < len) {
|
|
var c = trimmed.charAt(j);
|
|
if (c === ' ' || c === '(' || c === '|' || c === ')') break;
|
|
j++;
|
|
}
|
|
var token = trimmed.slice(i, j);
|
|
if (token.length > 0) {
|
|
var node = parseToken(token);
|
|
if (node !== null) ast.push(node);
|
|
}
|
|
i = j;
|
|
}
|
|
}
|
|
|
|
// Group pipes into OR nodes
|
|
var hasPipe = false;
|
|
var branches = [[]];
|
|
for (var l = 0; l < ast.length; l++) {
|
|
if (ast[l].type === 'pipe') {
|
|
hasPipe = true;
|
|
branches.push([]);
|
|
} else {
|
|
branches[branches.length - 1].push(ast[l]);
|
|
}
|
|
}
|
|
branches = branches.filter(function(b) { return b.length > 0; });
|
|
|
|
if (!hasPipe) {
|
|
return ast.filter(function(n) { return n.type !== 'pipe'; });
|
|
}
|
|
|
|
var orNodes = branches.map(function(branch) {
|
|
if (branch.length === 1) return branch[0];
|
|
return { type: 'and', nodes: branch };
|
|
});
|
|
return [{ type: 'or', nodes: orNodes }];
|
|
}
|
|
|
|
// Check if a single node matches the value
|
|
function nodeMatches(node, value) {
|
|
switch (node.type) {
|
|
case 'wildcard-all': return true;
|
|
case 'match': return node.re.test(value);
|
|
case 'no-match': return !node.re.test(value);
|
|
case 'or':
|
|
for (var i = 0; i < node.nodes.length; i++) {
|
|
if (nodeMatches(node.nodes[i], value)) return true;
|
|
}
|
|
return false;
|
|
case 'and':
|
|
for (var i = 0; i < node.nodes.length; i++) {
|
|
if (!nodeMatches(node.nodes[i], value)) return false;
|
|
}
|
|
return true;
|
|
default: return false;
|
|
}
|
|
}
|
|
|
|
// Evaluate AST against value
|
|
function matches(value, ast) {
|
|
if (!ast || ast.length === 0) return true;
|
|
var v = String(value); // no forced lowercase — regex has 'i' flag
|
|
for (var i = 0; i < ast.length; i++) {
|
|
if (!nodeMatches(ast[i], v)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (!window.zddc) {
|
|
throw new Error('shared/zddc-filter.js: window.zddc must be loaded first');
|
|
}
|
|
window.zddc.filter = { parse: parse, matches: matches };
|
|
})();
|
|
|
|
/**
|
|
* Store Module
|
|
* Single source of truth for all application state
|
|
* Manages files, folders, sorting, filtering
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
// State
|
|
const state = {
|
|
// Directory structure
|
|
rootHandle: null,
|
|
folderTree: [],
|
|
selectedFolders: new Set(),
|
|
|
|
// Files
|
|
allFiles: [], // All files from selected folders
|
|
displayFiles: [], // After sorting and filtering
|
|
|
|
// Sort state
|
|
sortColumns: [], // [{column: 'original', direction: 'asc'}]
|
|
|
|
// Filter state
|
|
filters: {}, // {columnName: AST (from zddc.filter.parse)}
|
|
|
|
// UI state
|
|
hideCompliant: false,
|
|
calculateSha256: false
|
|
};
|
|
|
|
// Listeners for state changes
|
|
const listeners = {
|
|
'files': [],
|
|
'folders': [],
|
|
'sort': [],
|
|
'filter': []
|
|
};
|
|
|
|
/**
|
|
* Set sort columns
|
|
*/
|
|
function on(event, callback) {
|
|
if (listeners[event]) {
|
|
listeners[event].push(callback);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Notify listeners of state change
|
|
*/
|
|
function notify(event) {
|
|
if (listeners[event]) {
|
|
listeners[event].forEach(cb => cb());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set root directory handle
|
|
*/
|
|
function setRootHandle(handle) {
|
|
state.rootHandle = handle;
|
|
}
|
|
|
|
/**
|
|
* Set folder tree
|
|
*/
|
|
function setFolderTree(tree) {
|
|
state.folderTree = tree;
|
|
notify('folders');
|
|
}
|
|
|
|
/**
|
|
* Select/deselect folder
|
|
*/
|
|
function toggleFolder(folderPath) {
|
|
if (state.selectedFolders.has(folderPath)) {
|
|
state.selectedFolders.delete(folderPath);
|
|
} else {
|
|
state.selectedFolders.add(folderPath);
|
|
}
|
|
loadFilesFromSelectedFolders();
|
|
}
|
|
|
|
/**
|
|
* Select multiple folders
|
|
*/
|
|
function setSelectedFolders(folderPaths) {
|
|
state.selectedFolders.clear();
|
|
folderPaths.forEach(path => state.selectedFolders.add(path));
|
|
loadFilesFromSelectedFolders();
|
|
}
|
|
|
|
/**
|
|
* Load files from selected folders
|
|
*/
|
|
function loadFilesFromSelectedFolders() {
|
|
state.allFiles = [];
|
|
|
|
if (state.selectedFolders.size === 0) {
|
|
updateDisplayFiles();
|
|
return;
|
|
}
|
|
|
|
// Collect files from selected folders
|
|
for (const folderPath of state.selectedFolders) {
|
|
const folder = findFolderByPath(folderPath);
|
|
if (folder && folder.files) {
|
|
const files = folder.files.filter(f => !f.isDirectory);
|
|
state.allFiles.push(...files);
|
|
}
|
|
}
|
|
|
|
// Apply default sort if no sort set
|
|
if (state.sortColumns.length === 0) {
|
|
state.sortColumns = [{ column: 'original', direction: 'asc' }];
|
|
}
|
|
|
|
updateDisplayFiles();
|
|
}
|
|
|
|
/**
|
|
* Find folder by path in tree
|
|
*/
|
|
function findFolderByPath(path) {
|
|
function search(folders) {
|
|
for (const folder of folders) {
|
|
if (folder.path === path) return folder;
|
|
if (folder.children) {
|
|
const found = search(folder.children);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
return search(state.folderTree);
|
|
}
|
|
|
|
/**
|
|
* Update display files (apply sort, filter, hide compliant)
|
|
*/
|
|
function updateDisplayFiles() {
|
|
let files = [...state.allFiles];
|
|
|
|
// Apply filters
|
|
files = applyFilters(files);
|
|
|
|
// Apply hide compliant
|
|
if (state.hideCompliant) {
|
|
files = files.filter(file => {
|
|
const newFilename = computeNewFilename(file);
|
|
const validation = validateFilename(newFilename);
|
|
return !validation.isValid;
|
|
});
|
|
}
|
|
|
|
// Apply sort
|
|
files = applySort(files);
|
|
|
|
state.displayFiles = files;
|
|
notify('files');
|
|
}
|
|
|
|
/**
|
|
* Apply filters to files using zddc.filter ASTs
|
|
*/
|
|
function applyFilters(files) {
|
|
if (Object.keys(state.filters).length === 0) {
|
|
return files;
|
|
}
|
|
|
|
return files.filter(file => {
|
|
for (const [columnName, ast] of Object.entries(state.filters)) {
|
|
const value = getColumnValue(file, columnName);
|
|
if (!window.zddc.filter.matches(value, ast)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Apply sort to files
|
|
*/
|
|
function applySort(files) {
|
|
if (state.sortColumns.length === 0) {
|
|
return files;
|
|
}
|
|
|
|
return files.sort((a, b) => {
|
|
for (const sort of state.sortColumns) {
|
|
const result = compareValues(a, b, sort.column, sort.direction);
|
|
if (result !== 0) return result;
|
|
}
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Compare two values for sorting
|
|
*/
|
|
function compareValues(a, b, columnName, direction) {
|
|
let aVal = getColumnValue(a, columnName);
|
|
let bVal = getColumnValue(b, columnName);
|
|
|
|
const comparison = String(aVal).localeCompare(String(bVal), undefined, {
|
|
numeric: true,
|
|
sensitivity: 'base'
|
|
});
|
|
|
|
return direction === 'asc' ? comparison : -comparison;
|
|
}
|
|
|
|
/**
|
|
* Get column value from file (delegates to utils)
|
|
*/
|
|
function getColumnValue(file, columnName) {
|
|
return window.app.modules.utils.getColumnValue(file, columnName);
|
|
}
|
|
|
|
function computeNewFilename(file) {
|
|
return window.app.modules.utils.computeNewFilename(file);
|
|
}
|
|
|
|
/**
|
|
* Validate filename
|
|
*/
|
|
function validateFilename(filename) {
|
|
// Use existing validator module
|
|
if (window.app.modules.validator) {
|
|
return window.app.modules.validator.validateFilename(filename);
|
|
}
|
|
return { isValid: true, errors: [], warnings: [] };
|
|
}
|
|
|
|
/**
|
|
* Match filter text against value
|
|
*/
|
|
function matchesFilter(value, filterText) {
|
|
// Simple contains for now - can enhance later
|
|
return String(value).toLowerCase().includes(filterText.toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* Set sort columns
|
|
*/
|
|
function setSortColumns(columns) {
|
|
state.sortColumns = columns;
|
|
updateDisplayFiles();
|
|
}
|
|
|
|
/**
|
|
* Toggle sort on column
|
|
*/
|
|
function toggleSort(columnName, multiSort) {
|
|
if (!multiSort) {
|
|
state.sortColumns = [];
|
|
}
|
|
|
|
const existingIndex = state.sortColumns.findIndex(s => s.column === columnName);
|
|
|
|
if (existingIndex >= 0) {
|
|
const current = state.sortColumns[existingIndex];
|
|
if (current.direction === 'asc') {
|
|
current.direction = 'desc';
|
|
} else {
|
|
state.sortColumns.splice(existingIndex, 1);
|
|
}
|
|
} else {
|
|
state.sortColumns.push({ column: columnName, direction: 'asc' });
|
|
}
|
|
|
|
updateDisplayFiles();
|
|
notify('sort');
|
|
}
|
|
|
|
/**
|
|
* Set filter for column. ast is the pre-parsed zddc.filter AST.
|
|
*/
|
|
function setFilter(columnName, filterText, ast) {
|
|
if (filterText && ast && ast.length > 0) {
|
|
state.filters[columnName] = ast;
|
|
} else {
|
|
delete state.filters[columnName];
|
|
}
|
|
updateDisplayFiles();
|
|
}
|
|
|
|
/**
|
|
* Replace all filters at once. filtersObj is {columnName: rawString}.
|
|
* Parses each value. Pass {} to clear all filters.
|
|
*/
|
|
function setAllFilters(filtersObj) {
|
|
state.filters = {};
|
|
for (const [columnName, raw] of Object.entries(filtersObj)) {
|
|
if (raw) {
|
|
const ast = window.zddc.filter.parse(raw);
|
|
if (ast && ast.length > 0) {
|
|
state.filters[columnName] = ast;
|
|
}
|
|
}
|
|
}
|
|
updateDisplayFiles();
|
|
}
|
|
|
|
/**
|
|
* Set hide compliant flag
|
|
*/
|
|
function setHideCompliant(hide) {
|
|
state.hideCompliant = hide;
|
|
updateDisplayFiles();
|
|
}
|
|
|
|
/**
|
|
* Update file data
|
|
*/
|
|
function updateFile(index, updates) {
|
|
const file = state.displayFiles[index];
|
|
if (!file) return;
|
|
|
|
// Apply updates
|
|
Object.assign(file, updates);
|
|
|
|
// Mark as dirty unless explicitly set to false
|
|
if (updates.isDirty !== false) {
|
|
file.isDirty = true;
|
|
}
|
|
|
|
// Notify listeners (will trigger re-render)
|
|
notify('files');
|
|
}
|
|
|
|
/**
|
|
* Update file field (for editing)
|
|
*/
|
|
function updateFileField(index, fieldName, value) {
|
|
const file = state.displayFiles[index];
|
|
if (!file) return;
|
|
|
|
file[fieldName] = value;
|
|
file.autoPopulated = false; // Clear auto-populated flag
|
|
|
|
// Re-evaluate dirty: if every field still matches the parsed original,
|
|
// and there is no manual filename override, the file is clean again.
|
|
file.isDirty = _isFileDirty(file);
|
|
|
|
// Notify listeners
|
|
notify('files');
|
|
}
|
|
|
|
/**
|
|
* A file is dirty if its computed filename differs from the original,
|
|
* or if it has a manual filename override.
|
|
*/
|
|
function _isFileDirty(file) {
|
|
if (file.manualFilename) return true;
|
|
const computed = zddc.formatFilename({
|
|
trackingNumber: file.trackingNumber || '',
|
|
revision: file.revision || '',
|
|
status: file.status || '',
|
|
title: file.title || '',
|
|
extension: file.extension || '',
|
|
});
|
|
const original = zddc.joinExtension(file.originalFilename, file.extension);
|
|
// If formatFilename returns '' (missing fields) fall back to original — not dirty
|
|
return computed !== '' && computed !== original;
|
|
}
|
|
|
|
/**
|
|
* Get display files (what should be shown in table)
|
|
*/
|
|
function getDisplayFiles() {
|
|
return state.displayFiles;
|
|
}
|
|
|
|
/**
|
|
* Get all files (unfiltered)
|
|
*/
|
|
function getAllFiles() {
|
|
return state.allFiles;
|
|
}
|
|
|
|
/**
|
|
* Get sort columns
|
|
*/
|
|
function getSortColumns() {
|
|
return state.sortColumns;
|
|
}
|
|
|
|
/**
|
|
* Get selected folder count
|
|
*/
|
|
function getSelectedFolderCount() {
|
|
return state.selectedFolders.size;
|
|
}
|
|
|
|
/**
|
|
* Get state (read-only)
|
|
*/
|
|
function getState() {
|
|
return {
|
|
rootHandle: state.rootHandle,
|
|
folderTree: state.folderTree,
|
|
selectedFolders: Array.from(state.selectedFolders),
|
|
allFiles: state.allFiles,
|
|
displayFiles: state.displayFiles,
|
|
sortColumns: state.sortColumns,
|
|
filters: state.filters,
|
|
hideCompliant: state.hideCompliant
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reset all state
|
|
*/
|
|
function reset() {
|
|
state.rootHandle = null;
|
|
state.folderTree = [];
|
|
state.selectedFolders.clear();
|
|
state.allFiles = [];
|
|
state.displayFiles = [];
|
|
state.sortColumns = [];
|
|
state.filters = {};
|
|
state.hideCompliant = false;
|
|
|
|
notify('files');
|
|
notify('folders');
|
|
}
|
|
|
|
// Export
|
|
window.app.modules.store = {
|
|
on,
|
|
notify,
|
|
setRootHandle,
|
|
setFolderTree,
|
|
toggleFolder,
|
|
setSelectedFolders,
|
|
toggleSort,
|
|
setFilter,
|
|
setAllFilters,
|
|
setHideCompliant,
|
|
updateFile,
|
|
updateFileField,
|
|
getDisplayFiles,
|
|
getAllFiles,
|
|
getSortColumns,
|
|
getSelectedFolderCount,
|
|
getState,
|
|
reset
|
|
};
|
|
})();
|
|
|
|
/**
|
|
* ZDDC Validation Module
|
|
* Validates file names against ZDDC conventions using the shared zddc library.
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
/**
|
|
* Validate a filename and return a detailed result.
|
|
* Delegates ZDDC pattern checking to the shared zddc.parseFilename() library.
|
|
*/
|
|
function validateFilename(filename) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
if (!filename) {
|
|
errors.push('Filename is empty.');
|
|
return { isValid: false, warnings, errors };
|
|
}
|
|
|
|
const parsed = zddc.parseFilename(filename);
|
|
if (!parsed || !parsed.valid) {
|
|
errors.push('Filename does not match ZDDC format: trackingNumber_revision (status) - title.ext');
|
|
} else if (!zddc.isValidStatus(parsed.status)) {
|
|
errors.push('Invalid status code "' + parsed.status + '". Valid codes: ' + zddc.STATUSES.join(', '));
|
|
}
|
|
|
|
if (filename.length > 255) {
|
|
warnings.push('Filename is very long (>255 characters)');
|
|
}
|
|
|
|
const invalidChars = /[<>:"|?*]/;
|
|
if (invalidChars.test(filename)) {
|
|
errors.push('Filename contains invalid characters: < > : " | ? *');
|
|
}
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
warnings,
|
|
errors
|
|
};
|
|
}
|
|
|
|
window.app.modules.validator = {
|
|
validateFilename
|
|
};
|
|
})();
|
|
|
|
/**
|
|
* Directory Scanner Module
|
|
* Scans directories and collects files
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Store ZIP data for later access
|
|
const zipCache = new Map(); // path -> { zip: JSZip, fileHandle: FileSystemFileHandle }
|
|
|
|
/**
|
|
* Scan directory and build folder tree with files
|
|
*/
|
|
async function scanDirectory(dirHandle, preserveState = false) {
|
|
|
|
|
|
// Save current state if preserving
|
|
let savedExpanded = new Set();
|
|
let savedSelected = new Set();
|
|
if (preserveState) {
|
|
savedExpanded = getExpandedPaths(window.app.folderTree);
|
|
savedSelected = new Set(window.app.selectedFolders);
|
|
}
|
|
|
|
// Clear ZIP cache
|
|
zipCache.clear();
|
|
|
|
// Map to store files by folder handle (or ZIP path for virtual folders)
|
|
const foldersMap = new Map();
|
|
|
|
// Recursively scan
|
|
await scanFolder(dirHandle, foldersMap, dirHandle.name);
|
|
|
|
// Build tree structure
|
|
window.app.folderTree = window.app.modules.tree.buildTree(dirHandle, foldersMap);
|
|
|
|
// Set in store
|
|
window.app.modules.store.setFolderTree(window.app.folderTree);
|
|
|
|
if (preserveState) {
|
|
// Restore expanded state
|
|
restoreExpandedPaths(window.app.folderTree, savedExpanded);
|
|
// Restore selection
|
|
window.app.selectedFolders = savedSelected;
|
|
// Render without changing selection
|
|
window.app.modules.tree.render();
|
|
window.app.modules.store.setSelectedFolders(savedSelected);
|
|
} else {
|
|
// Render tree
|
|
window.app.modules.tree.render();
|
|
// Auto-expand and select all folders
|
|
window.app.modules.tree.expandAll();
|
|
window.app.modules.tree.selectAll();
|
|
}
|
|
|
|
|
|
}
|
|
|
|
/**
|
|
* Get all expanded folder paths from tree
|
|
*/
|
|
function getExpandedPaths(folders, paths = new Set()) {
|
|
for (const folder of folders) {
|
|
if (folder.expanded) {
|
|
paths.add(folder.path);
|
|
}
|
|
if (folder.children) {
|
|
getExpandedPaths(folder.children, paths);
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
/**
|
|
* Restore expanded state to tree
|
|
*/
|
|
function restoreExpandedPaths(folders, expandedPaths) {
|
|
for (const folder of folders) {
|
|
folder.expanded = expandedPaths.has(folder.path);
|
|
if (folder.children) {
|
|
restoreExpandedPaths(folder.children, expandedPaths);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively scan a folder
|
|
*/
|
|
async function scanFolder(dirHandle, foldersMap, currentPath) {
|
|
const items = [];
|
|
|
|
try {
|
|
for await (const entry of dirHandle.values()) {
|
|
if (entry.kind === 'file') {
|
|
// Create file object
|
|
const file = await createFileObject(entry, dirHandle);
|
|
if (file) {
|
|
items.push(file);
|
|
|
|
// Check if it's a ZIP file - scan its contents
|
|
if (file.extension === 'zip' && typeof JSZip !== 'undefined') {
|
|
await scanZipFile(file, foldersMap, currentPath, items);
|
|
}
|
|
}
|
|
} else if (entry.kind === 'directory') {
|
|
// Add directory reference
|
|
items.push({
|
|
handle: entry,
|
|
isDirectory: true
|
|
});
|
|
|
|
// Recursively scan subdirectory
|
|
const childPath = currentPath + '/' + entry.name;
|
|
await scanFolder(entry, foldersMap, childPath);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error scanning folder:', dirHandle.name, err);
|
|
}
|
|
|
|
// Store files for this folder
|
|
foldersMap.set(dirHandle, items);
|
|
}
|
|
|
|
/**
|
|
* Scan a ZIP file and add its contents as virtual folders
|
|
*/
|
|
async function scanZipFile(zipFileObj, foldersMap, parentPath, parentItems) {
|
|
try {
|
|
const fileObj = await zipFileObj.handle.getFile();
|
|
const arrayBuffer = await fileObj.arrayBuffer();
|
|
const zip = await JSZip.loadAsync(arrayBuffer);
|
|
|
|
const zipPath = parentPath + '/' + zddc.joinExtension(zipFileObj.originalFilename, zipFileObj.extension);
|
|
|
|
// Cache the ZIP for later extraction
|
|
zipCache.set(zipPath, {
|
|
zip: zip,
|
|
fileHandle: zipFileObj.handle,
|
|
folderHandle: zipFileObj.folderHandle
|
|
});
|
|
|
|
// Mark the file as a ZIP container
|
|
zipFileObj.isZipContainer = true;
|
|
zipFileObj.zipPath = zipPath;
|
|
|
|
// Build virtual folder structure from ZIP contents
|
|
const virtualFolders = new Map(); // path -> { files: [], subdirs: Set }
|
|
virtualFolders.set(zipPath, { files: [], subdirs: new Set() });
|
|
|
|
zip.forEach((relativePath, zipEntry) => {
|
|
if (zipEntry.dir) {
|
|
// It's a directory
|
|
const dirPath = zipPath + '/' + relativePath.replace(/\/$/, '');
|
|
if (!virtualFolders.has(dirPath)) {
|
|
virtualFolders.set(dirPath, { files: [], subdirs: new Set() });
|
|
}
|
|
// Add to parent's subdirs
|
|
const parentDir = dirPath.substring(0, dirPath.lastIndexOf('/'));
|
|
if (virtualFolders.has(parentDir)) {
|
|
virtualFolders.get(parentDir).subdirs.add(dirPath);
|
|
}
|
|
} else {
|
|
// It's a file
|
|
const fileName = relativePath.split('/').pop();
|
|
const fileDir = relativePath.includes('/')
|
|
? zipPath + '/' + relativePath.substring(0, relativePath.lastIndexOf('/'))
|
|
: zipPath;
|
|
|
|
// Ensure parent directories exist
|
|
ensureVirtualPath(virtualFolders, zipPath, fileDir);
|
|
|
|
// Create virtual file object
|
|
const split = zddc.splitExtension(fileName);
|
|
|
|
const virtualFile = {
|
|
originalFilename: split.name,
|
|
extension: split.extension,
|
|
size: zipEntry._data ? zipEntry._data.uncompressedSize : 0,
|
|
lastModified: zipEntry.date ? zipEntry.date.getTime() : Date.now(),
|
|
|
|
// Virtual file markers
|
|
isVirtual: true,
|
|
zipPath: zipPath,
|
|
zipEntryPath: relativePath,
|
|
|
|
// Editable fields
|
|
trackingNumber: '',
|
|
revision: '',
|
|
status: '',
|
|
title: '',
|
|
|
|
// State
|
|
isDirty: false,
|
|
error: false,
|
|
errorMessage: '',
|
|
validation: null,
|
|
sha256: null
|
|
};
|
|
|
|
virtualFolders.get(fileDir).files.push(virtualFile);
|
|
}
|
|
});
|
|
|
|
// Convert virtual folders to format compatible with tree builder
|
|
// Create a virtual handle for the ZIP root
|
|
const zipVirtualHandle = {
|
|
name: zddc.joinExtension(zipFileObj.originalFilename, zipFileObj.extension),
|
|
kind: 'directory',
|
|
isZipRoot: true,
|
|
zipPath: zipPath
|
|
};
|
|
|
|
// Store virtual folder data
|
|
buildVirtualFolderMap(virtualFolders, zipPath, foldersMap, zipVirtualHandle);
|
|
|
|
// Add ZIP as a virtual directory in parent
|
|
parentItems.push({
|
|
handle: zipVirtualHandle,
|
|
isDirectory: true,
|
|
isZipRoot: true
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error('Error scanning ZIP file:', zipFileObj.originalFilename, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure all parent directories exist in virtual folder map
|
|
*/
|
|
function ensureVirtualPath(virtualFolders, zipPath, targetPath) {
|
|
if (virtualFolders.has(targetPath)) return;
|
|
|
|
const parts = targetPath.substring(zipPath.length + 1).split('/').filter(p => p);
|
|
let currentPath = zipPath;
|
|
|
|
for (const part of parts) {
|
|
const parentPath = currentPath;
|
|
currentPath = currentPath + '/' + part;
|
|
|
|
if (!virtualFolders.has(currentPath)) {
|
|
virtualFolders.set(currentPath, { files: [], subdirs: new Set() });
|
|
}
|
|
|
|
if (virtualFolders.has(parentPath)) {
|
|
virtualFolders.get(parentPath).subdirs.add(currentPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build virtual folder entries for the foldersMap
|
|
* Uses path strings as keys for virtual folders to avoid object reference issues
|
|
*/
|
|
function buildVirtualFolderMap(virtualFolders, zipPath, foldersMap, zipVirtualHandle) {
|
|
const rootData = virtualFolders.get(zipPath);
|
|
if (!rootData) return;
|
|
|
|
// Create items array for ZIP root
|
|
const rootItems = [...rootData.files];
|
|
|
|
// Add subdirectories
|
|
for (const subdirPath of rootData.subdirs) {
|
|
const subdirName = subdirPath.split('/').pop();
|
|
const subdirHandle = {
|
|
name: subdirName,
|
|
kind: 'directory',
|
|
isVirtualDir: true,
|
|
virtualPath: subdirPath,
|
|
zipPath: zipPath
|
|
};
|
|
rootItems.push({
|
|
handle: subdirHandle,
|
|
isDirectory: true,
|
|
isVirtualDir: true
|
|
});
|
|
|
|
// Recursively add subdir contents
|
|
buildVirtualSubfolder(virtualFolders, subdirPath, foldersMap, zipPath);
|
|
}
|
|
|
|
// Store with both the handle object AND the path string as keys
|
|
// This ensures lookup works regardless of which reference is used
|
|
foldersMap.set(zipVirtualHandle, rootItems);
|
|
foldersMap.set(zipPath, rootItems); // Path-based key for tree building
|
|
}
|
|
|
|
/**
|
|
* Recursively build virtual subfolder entries
|
|
*/
|
|
function buildVirtualSubfolder(virtualFolders, folderPath, foldersMap, zipPath) {
|
|
const folderData = virtualFolders.get(folderPath);
|
|
if (!folderData) return;
|
|
|
|
const folderName = folderPath.split('/').pop();
|
|
const folderHandle = {
|
|
name: folderName,
|
|
kind: 'directory',
|
|
isVirtualDir: true,
|
|
virtualPath: folderPath,
|
|
zipPath: zipPath
|
|
};
|
|
|
|
const items = [...folderData.files];
|
|
|
|
// Store with path string key for tree building lookup
|
|
foldersMap.set(folderPath, items);
|
|
|
|
// Add subdirectories
|
|
for (const subdirPath of folderData.subdirs) {
|
|
const subdirName = subdirPath.split('/').pop();
|
|
const subdirHandle = {
|
|
name: subdirName,
|
|
kind: 'directory',
|
|
isVirtualDir: true,
|
|
virtualPath: subdirPath,
|
|
zipPath: zipPath
|
|
};
|
|
items.push({
|
|
handle: subdirHandle,
|
|
isDirectory: true,
|
|
isVirtualDir: true
|
|
});
|
|
|
|
// Recursively add subdir contents
|
|
buildVirtualSubfolder(virtualFolders, subdirPath, foldersMap, zipPath);
|
|
}
|
|
|
|
foldersMap.set(folderHandle, items);
|
|
}
|
|
|
|
/**
|
|
* Get cached ZIP data
|
|
*/
|
|
function getZipCache(zipPath) {
|
|
return zipCache.get(zipPath);
|
|
}
|
|
|
|
/**
|
|
* Extract a ZIP file to its parent directory
|
|
*/
|
|
async function extractZip(zipPath) {
|
|
const cached = zipCache.get(zipPath);
|
|
if (!cached) {
|
|
throw new Error('ZIP not found in cache');
|
|
}
|
|
|
|
const { zip, folderHandle } = cached;
|
|
|
|
// Get the ZIP filename without extension for the extract folder name
|
|
const zipName = zipPath.split('/').pop();
|
|
const extractFolderName = zipName.replace(/\.zip$/i, '');
|
|
|
|
// Create extraction folder
|
|
const extractFolder = await folderHandle.getDirectoryHandle(extractFolderName, { create: true });
|
|
|
|
// Extract all files
|
|
const entries = [];
|
|
zip.forEach((relativePath, zipEntry) => {
|
|
if (!zipEntry.dir) {
|
|
entries.push({ path: relativePath, entry: zipEntry });
|
|
}
|
|
});
|
|
|
|
for (const { path, entry } of entries) {
|
|
try {
|
|
// Create subdirectories if needed
|
|
const parts = path.split('/');
|
|
const fileName = parts.pop();
|
|
|
|
let currentDir = extractFolder;
|
|
for (const part of parts) {
|
|
if (part) {
|
|
currentDir = await currentDir.getDirectoryHandle(part, { create: true });
|
|
}
|
|
}
|
|
|
|
// Write file
|
|
const content = await entry.async('arraybuffer');
|
|
const fileHandle = await currentDir.getFileHandle(fileName, { create: true });
|
|
const writable = await fileHandle.createWritable();
|
|
await writable.write(content);
|
|
await writable.close();
|
|
} catch (err) {
|
|
console.error('Error extracting file:', path, err);
|
|
}
|
|
}
|
|
|
|
return extractFolderName;
|
|
}
|
|
|
|
/**
|
|
* Create file object with metadata
|
|
*/
|
|
async function createFileObject(fileHandle, folderHandle) {
|
|
try {
|
|
const file = await fileHandle.getFile();
|
|
const split = zddc.splitExtension(file.name);
|
|
|
|
return {
|
|
handle: fileHandle,
|
|
folderHandle: folderHandle,
|
|
originalFilename: split.name,
|
|
extension: split.extension,
|
|
size: file.size,
|
|
lastModified: file.lastModified,
|
|
|
|
// Editable fields
|
|
trackingNumber: '',
|
|
revision: '',
|
|
status: '',
|
|
title: '',
|
|
|
|
// State
|
|
isDirty: false,
|
|
error: false,
|
|
errorMessage: '',
|
|
validation: null,
|
|
sha256: null
|
|
// folderPath will be added later in buildTree
|
|
};
|
|
} catch (err) {
|
|
console.error('Error reading file:', fileHandle.name, err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Export module
|
|
window.app.modules.scanner = {
|
|
scanDirectory,
|
|
getZipCache,
|
|
extractZip
|
|
};
|
|
})();
|
|
|
|
|
|
/**
|
|
* Folder Tree Module
|
|
* Handles folder tree rendering and multi-select
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
/**
|
|
* Render the folder tree
|
|
*/
|
|
function render() {
|
|
const container = window.app.dom.folderTree;
|
|
container.innerHTML = '';
|
|
|
|
if (window.app.folderTree.length === 0) {
|
|
container.innerHTML = '<div class="empty-state">No folders found</div>';
|
|
return;
|
|
}
|
|
|
|
window.app.folderTree.forEach(folder => {
|
|
const element = createFolderElement(folder);
|
|
container.appendChild(element);
|
|
});
|
|
|
|
updateSelectedCount();
|
|
}
|
|
|
|
/**
|
|
* Create a folder element
|
|
*/
|
|
function createFolderElement(folder, level = 0) {
|
|
const div = document.createElement('div');
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'folder-item';
|
|
item.dataset.path = folder.path;
|
|
item.style.paddingLeft = `${level * 1.5}rem`;
|
|
|
|
// Check if selected
|
|
if (window.app.selectedFolders.has(folder.path)) {
|
|
item.classList.add('selected');
|
|
}
|
|
|
|
// Toggle button (if has children)
|
|
const toggle = document.createElement('span');
|
|
toggle.className = 'folder-toggle';
|
|
if (folder.children && folder.children.length > 0) {
|
|
toggle.textContent = folder.expanded ? '▼' : '▶';
|
|
toggle.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const recursive = e.ctrlKey || e.metaKey;
|
|
toggleFolder(folder, recursive);
|
|
});
|
|
} else {
|
|
toggle.textContent = ' ';
|
|
}
|
|
item.appendChild(toggle);
|
|
|
|
// Folder icon (different for ZIP files)
|
|
const icon = document.createElement('span');
|
|
icon.className = 'folder-icon';
|
|
if (folder.isZipRoot) {
|
|
icon.innerHTML = '📦'; // 📦
|
|
} 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;
|
|
|
|
// File type mappings (extensions stored without leading dot, matching shared/zddc.js)
|
|
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'];
|
|
const TEXT_EXTENSIONS = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'html', 'css', 'js', 'ts', 'py', 'sh', 'bat', 'yaml', 'yml', 'ini', 'cfg', 'conf'];
|
|
const PDF_EXTENSIONS = ['pdf'];
|
|
const ZIP_EXTENSIONS = ['zip'];
|
|
|
|
// Cache for lazily loaded CDN libraries
|
|
const loadedLibraries = new Map();
|
|
|
|
/**
|
|
* Lazily load a script from CDN. Returns a promise that resolves when loaded.
|
|
* Caches the promise so subsequent calls return immediately.
|
|
*/
|
|
function loadLibrary(url) {
|
|
if (loadedLibraries.has(url)) return loadedLibraries.get(url);
|
|
const promise = new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = url;
|
|
script.onload = resolve;
|
|
script.onerror = () => reject(new Error(`Failed to load library: ${url}`));
|
|
document.head.appendChild(script);
|
|
});
|
|
loadedLibraries.set(url, promise);
|
|
return promise;
|
|
}
|
|
|
|
/**
|
|
* Initialize preview module
|
|
*/
|
|
function init() {
|
|
// Listen for row focused events from selection module
|
|
document.addEventListener('rowfocused', handleRowFocused);
|
|
|
|
// Set up toggle button to open/close preview window
|
|
const toggleBtn = document.getElementById('togglePreviewBtn');
|
|
if (toggleBtn) {
|
|
toggleBtn.addEventListener('click', () => {
|
|
if (previewWindow && !previewWindow.closed) {
|
|
// Close preview window
|
|
previewWindow.close();
|
|
previewWindow = null;
|
|
toggleBtn.classList.remove('preview-active');
|
|
} else if (currentFile) {
|
|
openPreviewWindow(currentFile);
|
|
toggleBtn.classList.add('preview-active');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle row focused event
|
|
*/
|
|
function handleRowFocused(e) {
|
|
const { rowIndex, file } = e.detail;
|
|
|
|
currentRowIndex = rowIndex;
|
|
|
|
if (file && file !== currentFile) {
|
|
currentFile = file;
|
|
|
|
// Update preview window if open
|
|
if (previewWindow && !previewWindow.closed) {
|
|
openPreviewWindow(file);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open preview in a separate popup window
|
|
*/
|
|
async function openPreviewWindow(file) {
|
|
if (!file) return;
|
|
|
|
currentFile = file;
|
|
|
|
try {
|
|
const blob = await getFileBlob(file);
|
|
|
|
// Clean up previous blob URL
|
|
if (currentBlobUrl) {
|
|
URL.revokeObjectURL(currentBlobUrl);
|
|
}
|
|
currentBlobUrl = URL.createObjectURL(blob);
|
|
|
|
const fileName = zddc.joinExtension(file.originalFilename, file.extension);
|
|
|
|
// Build preview HTML with toolbar
|
|
const previewHtml = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>${escapeHtml(fileName)} - Preview</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
}
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
background: #f5f5f5;
|
|
border-bottom: 1px solid #ddd;
|
|
}
|
|
.toolbar h1 {
|
|
flex: 1;
|
|
font-size: 0.95rem;
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.btn {
|
|
padding: 0.4rem 0.8rem;
|
|
font-size: 0.85rem;
|
|
border: 1px solid #ccc;
|
|
border-radius: 4px;
|
|
background: white;
|
|
cursor: pointer;
|
|
}
|
|
.btn:hover { background: #e8e8e8; }
|
|
iframe, img {
|
|
flex: 1;
|
|
width: 100%;
|
|
border: none;
|
|
}
|
|
img {
|
|
object-fit: contain;
|
|
background: #f0f0f0;
|
|
}
|
|
pre {
|
|
flex: 1;
|
|
padding: 1rem;
|
|
overflow: auto;
|
|
background: #fafafa;
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|
font-size: 0.9rem;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
}
|
|
.unsupported {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #666;
|
|
}
|
|
.unsupported .icon { font-size: 3rem; margin-bottom: 1rem; }
|
|
#previewContent {
|
|
flex: 1;
|
|
overflow: auto;
|
|
}
|
|
.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: #666;
|
|
font-size: 1.1rem;
|
|
}
|
|
.docx-wrapper { padding: 1rem; }
|
|
.xlsx-table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
|
|
.xlsx-table th, .xlsx-table td {
|
|
border: 1px solid #ddd;
|
|
padding: 0.35rem 0.5rem;
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
}
|
|
.xlsx-table th { background: #f0f0f0; font-weight: 600; position: sticky; top: 0; }
|
|
.xlsx-table tr:nth-child(even) { background: #fafafa; }
|
|
.xlsx-table tr:hover { background: #f0f7ff; }
|
|
.sheet-tabs { display: flex; gap: 0; border-bottom: 1px solid #ddd; background: #f5f5f5; }
|
|
.sheet-tab {
|
|
padding: 0.4rem 1rem;
|
|
cursor: pointer;
|
|
border: 1px solid transparent;
|
|
border-bottom: none;
|
|
font-size: 0.85rem;
|
|
background: transparent;
|
|
}
|
|
.sheet-tab:hover { background: #e8e8e8; }
|
|
.sheet-tab.active {
|
|
background: white;
|
|
border-color: #ddd;
|
|
border-bottom-color: white;
|
|
margin-bottom: -1px;
|
|
font-weight: 500;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="toolbar">
|
|
<h1>${escapeHtml(fileName)}</h1>
|
|
<button class="btn" onclick="downloadFile()">Download</button>
|
|
</div>
|
|
${await getPreviewContent(file, currentBlobUrl)}
|
|
<script>
|
|
var blobUrl = "${currentBlobUrl}";
|
|
var fileName = "${escapeHtml(fileName).replace(/"/g, '\\"')}";
|
|
|
|
function downloadFile() {
|
|
const a = document.createElement('a');
|
|
a.href = blobUrl;
|
|
a.download = fileName;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
<\/script>
|
|
</body>
|
|
</html>`;
|
|
|
|
// Reuse existing window if open, otherwise create new one
|
|
if (previewWindow && !previewWindow.closed) {
|
|
previewWindow.document.open();
|
|
previewWindow.document.write(previewHtml);
|
|
previewWindow.document.close();
|
|
previewWindow.focus();
|
|
} else {
|
|
// Calculate window size
|
|
const width = Math.round(screen.width * 0.6);
|
|
const height = Math.round(screen.height * 0.8);
|
|
const left = Math.round((screen.width - width) / 2);
|
|
const top = Math.round((screen.height - height) / 2);
|
|
|
|
previewWindow = window.open('', 'classifierPreview',
|
|
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`);
|
|
|
|
if (!previewWindow) {
|
|
// Popup blocked - fall back to new tab
|
|
window.open(currentBlobUrl, '_blank');
|
|
return;
|
|
}
|
|
|
|
// Poll for window close — beforeunload is unreliable for popup close buttons
|
|
const closePoll = setInterval(() => {
|
|
if (previewWindow && previewWindow.closed) {
|
|
clearInterval(closePoll);
|
|
previewWindow = null;
|
|
const btn = document.getElementById('togglePreviewBtn');
|
|
if (btn) btn.classList.remove('preview-active');
|
|
}
|
|
}, 500);
|
|
|
|
previewWindow.document.write(previewHtml);
|
|
previewWindow.document.close();
|
|
previewWindow.focus();
|
|
}
|
|
|
|
// For office types, render content after window is ready
|
|
const ext = (file.extension || '').toLowerCase();
|
|
if (ext === 'docx') {
|
|
await renderDocxInWindow(file);
|
|
} else if (ext === 'xlsx' || ext === 'xls') {
|
|
await renderXlsxInWindow(file);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error opening preview:', err);
|
|
alert(`Error opening preview: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get preview content HTML based on file type
|
|
*/
|
|
async function getPreviewContent(file, blobUrl) {
|
|
const ext = file.extension.toLowerCase();
|
|
const previewType = getPreviewType(ext);
|
|
|
|
switch (previewType) {
|
|
case 'pdf':
|
|
return `<iframe src="${blobUrl}#view=FitV"></iframe>`;
|
|
case 'image':
|
|
return `<img src="${blobUrl}" alt="${escapeHtml(file.originalFilename)}" />`;
|
|
case 'text':
|
|
const text = await getFileText(file);
|
|
const maxLength = 100000;
|
|
const displayText = text.length > maxLength
|
|
? text.substring(0, maxLength) + '\n\n... (truncated)'
|
|
: text;
|
|
return `<pre>${escapeHtml(displayText)}</pre>`;
|
|
case 'docx':
|
|
case 'xlsx':
|
|
return `<div id="previewContent"><div class="loading">Loading preview...</div></div>`;
|
|
default:
|
|
return `
|
|
<div class="unsupported">
|
|
<div class="icon">📄</div>
|
|
<p>Preview not available for ${ext} files</p>
|
|
<p style="margin-top: 0.5rem;">Click Download to view in external application</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get preview type from extension
|
|
*/
|
|
function getPreviewType(ext) {
|
|
if (PDF_EXTENSIONS.includes(ext)) return 'pdf';
|
|
if (IMAGE_EXTENSIONS.includes(ext)) return 'image';
|
|
if (TEXT_EXTENSIONS.includes(ext)) return 'text';
|
|
if (ext === 'docx') return 'docx';
|
|
if (ext === 'xlsx' || ext === 'xls') return 'xlsx';
|
|
if (ZIP_EXTENSIONS.includes(ext)) return 'zip';
|
|
return 'none';
|
|
}
|
|
|
|
function getMimeType(ext) {
|
|
return window.app.modules.utils.getMimeType(ext);
|
|
}
|
|
|
|
/**
|
|
* Get file content as blob (handles both real and virtual files)
|
|
*/
|
|
async function getFileBlob(file) {
|
|
if (file.isVirtual) {
|
|
// Get from ZIP cache
|
|
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
|
|
if (!cached) throw new Error('ZIP not found in cache');
|
|
|
|
const zipEntry = cached.zip.file(file.zipEntryPath);
|
|
if (!zipEntry) throw new Error('File not found in ZIP');
|
|
|
|
// Get as arraybuffer and create blob with correct MIME type
|
|
const arrayBuffer = await zipEntry.async('arraybuffer');
|
|
const mimeType = getMimeType(file.extension);
|
|
return new Blob([arrayBuffer], { type: mimeType });
|
|
} else {
|
|
// Get from file handle
|
|
if (!file.handle) {
|
|
throw new Error('File handle not available');
|
|
}
|
|
return await file.handle.getFile();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get file content as text (handles both real and virtual files)
|
|
*/
|
|
async function getFileText(file) {
|
|
if (file.isVirtual) {
|
|
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
|
|
if (!cached) throw new Error('ZIP not found in cache');
|
|
|
|
const zipEntry = cached.zip.file(file.zipEntryPath);
|
|
if (!zipEntry) throw new Error('File not found in ZIP');
|
|
|
|
return await zipEntry.async('string');
|
|
} else {
|
|
if (!file.handle) {
|
|
throw new Error('File handle not available');
|
|
}
|
|
const fileObj = await file.handle.getFile();
|
|
return await fileObj.text();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render a DOCX file in the preview window using docx-preview library
|
|
*/
|
|
async function renderDocxInWindow(file) {
|
|
const container = previewWindow.document.getElementById('previewContent');
|
|
if (!container) return;
|
|
|
|
try {
|
|
await loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js');
|
|
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
|
|
|
|
const blob = await getFileBlob(file);
|
|
const arrayBuffer = await blob.arrayBuffer();
|
|
|
|
container.innerHTML = '';
|
|
await window.docx.renderAsync(arrayBuffer, container);
|
|
} catch (err) {
|
|
console.error('Error rendering DOCX:', err);
|
|
container.innerHTML = `<div class="loading">Error rendering DOCX: ${err.message}<br>Click Download to view in Word.</div>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render an XLSX/XLS file in the preview window using SheetJS
|
|
*/
|
|
async function renderXlsxInWindow(file) {
|
|
const container = previewWindow.document.getElementById('previewContent');
|
|
if (!container) return;
|
|
|
|
try {
|
|
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
|
|
|
|
const blob = await getFileBlob(file);
|
|
const arrayBuffer = await blob.arrayBuffer();
|
|
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
|
|
|
container.innerHTML = '';
|
|
|
|
if (workbook.SheetNames.length > 1) {
|
|
const tabs = previewWindow.document.createElement('div');
|
|
tabs.className = 'sheet-tabs';
|
|
workbook.SheetNames.forEach((name, i) => {
|
|
const tab = previewWindow.document.createElement('button');
|
|
tab.className = 'sheet-tab' + (i === 0 ? ' active' : '');
|
|
tab.textContent = name;
|
|
tab.onclick = () => {
|
|
tabs.querySelectorAll('.sheet-tab').forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
renderSheetInWindow(workbook, name, tableContainer);
|
|
};
|
|
tabs.appendChild(tab);
|
|
});
|
|
container.appendChild(tabs);
|
|
}
|
|
|
|
const tableContainer = previewWindow.document.createElement('div');
|
|
tableContainer.style.flex = '1';
|
|
tableContainer.style.overflow = 'auto';
|
|
container.appendChild(tableContainer);
|
|
|
|
renderSheetInWindow(workbook, workbook.SheetNames[0], tableContainer);
|
|
} catch (err) {
|
|
console.error('Error rendering XLSX:', err);
|
|
container.innerHTML = `<div class="loading">Error rendering spreadsheet: ${err.message}<br>Click Download to view in Excel.</div>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render a single sheet as an HTML table in the preview window
|
|
*/
|
|
function renderSheetInWindow(workbook, sheetName, container) {
|
|
const sheet = workbook.Sheets[sheetName];
|
|
const html = XLSX.utils.sheet_to_html(sheet, { editable: false });
|
|
container.innerHTML = html;
|
|
const table = container.querySelector('table');
|
|
if (table) table.className = 'xlsx-table';
|
|
}
|
|
|
|
/**
|
|
* Escape HTML for safe display
|
|
*/
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Download current file
|
|
*/
|
|
async function downloadFile() {
|
|
if (!currentFile) return;
|
|
|
|
try {
|
|
const blob = await getFileBlob(currentFile);
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = zddc.joinExtension(currentFile.originalFilename, currentFile.extension);
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
} catch (err) {
|
|
console.error('Error downloading file:', err);
|
|
alert('Error downloading file: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// Export module
|
|
window.app.modules.preview = {
|
|
init
|
|
};
|
|
})();
|
|
|
|
/**
|
|
* Column Resize Module
|
|
* Handles resizable table columns
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
let resizingColumn = null;
|
|
let startX = 0;
|
|
let startWidth = 0;
|
|
|
|
/**
|
|
* Initialize column resizing
|
|
*/
|
|
function init() {
|
|
const table = window.app.dom.spreadsheet;
|
|
const headers = table.querySelectorAll('thead th');
|
|
|
|
headers.forEach(th => {
|
|
// Skip if resize handle already exists
|
|
if (th.querySelector('.column-resizer')) return;
|
|
|
|
// Add resize handle
|
|
const resizer = document.createElement('div');
|
|
resizer.className = 'column-resizer';
|
|
th.appendChild(resizer);
|
|
|
|
// Mouse down on resizer
|
|
resizer.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
resizingColumn = th;
|
|
startX = e.pageX;
|
|
startWidth = th.offsetWidth;
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle mouse move during resize
|
|
*/
|
|
function handleMouseMove(e) {
|
|
if (!resizingColumn) return;
|
|
|
|
const diff = e.pageX - startX;
|
|
const newWidth = Math.max(50, startWidth + diff);
|
|
|
|
resizingColumn.style.width = newWidth + 'px';
|
|
resizingColumn.style.minWidth = newWidth + 'px';
|
|
resizingColumn.style.maxWidth = newWidth + 'px';
|
|
}
|
|
|
|
/**
|
|
* Handle mouse up - end resize
|
|
*/
|
|
function handleMouseUp() {
|
|
resizingColumn = null;
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
}
|
|
|
|
// Export module
|
|
window.app.modules.resize = {
|
|
init
|
|
};
|
|
})();
|
|
|
|
/**
|
|
* Filter Module
|
|
* Column filter UI: initialises static inputs in thead, wires events.
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
/**
|
|
* Initialize filtering — wire delegated events on thead.
|
|
* Filter inputs already exist in the static template; no dynamic injection needed.
|
|
*/
|
|
function init() {
|
|
const thead = window.app.dom.spreadsheet
|
|
? window.app.dom.spreadsheet.querySelector('thead')
|
|
: document.querySelector('#spreadsheet thead');
|
|
if (!thead) return;
|
|
|
|
thead.addEventListener('input', (e) => {
|
|
if (e.target.matches('.column-filter[data-filter-field]')) {
|
|
e.stopPropagation();
|
|
const field = e.target.getAttribute('data-filter-field');
|
|
const raw = e.target.value.trim();
|
|
const ast = window.zddc.filter.parse(raw);
|
|
window.app.modules.store.setFilter(field, raw, ast);
|
|
}
|
|
});
|
|
|
|
thead.addEventListener('keydown', (e) => {
|
|
if (!e.target.matches('.column-filter[data-filter-field]')) return;
|
|
if (e.key === 'Escape') {
|
|
e.target.value = '';
|
|
const field = e.target.getAttribute('data-filter-field');
|
|
window.app.modules.store.setFilter(field, '', null);
|
|
e.preventDefault();
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const inputs = Array.from(thead.querySelectorAll('.column-filter'));
|
|
const idx = inputs.indexOf(e.target);
|
|
if (idx !== -1) inputs[(idx + 1) % inputs.length].focus();
|
|
}
|
|
});
|
|
|
|
thead.addEventListener('click', (e) => {
|
|
if (e.target.matches('.column-filter')) {
|
|
e.stopPropagation();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear all filters — reset inputs and store.
|
|
*/
|
|
function clearFilters() {
|
|
document.querySelectorAll('.column-filter').forEach(input => {
|
|
input.value = '';
|
|
});
|
|
window.app.modules.store.setAllFilters({});
|
|
}
|
|
|
|
window.app.modules.filter = {
|
|
init,
|
|
clearFilters
|
|
};
|
|
})();
|
|
|
|
/**
|
|
* Sort Module
|
|
* Handles multi-column sorting for the spreadsheet
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Sort state: array of {column, direction}
|
|
let sortState = [];
|
|
|
|
/**
|
|
* Initialize sorting
|
|
*/
|
|
function init() {
|
|
const table = window.app.dom.spreadsheet;
|
|
const headers = table.querySelectorAll('thead th');
|
|
|
|
headers.forEach((th, index) => {
|
|
// Skip row number column
|
|
if (th.classList.contains('col-row-num')) return;
|
|
|
|
// Skip if already initialized
|
|
if (th.querySelector('.sort-indicator')) return;
|
|
|
|
// Make header clickable
|
|
th.style.cursor = 'pointer';
|
|
th.style.userSelect = 'none';
|
|
|
|
// Add sort indicator container
|
|
const sortIndicator = document.createElement('span');
|
|
sortIndicator.className = 'sort-indicator';
|
|
|
|
// Insert after the text node (before any br or filter)
|
|
const firstChild = th.firstChild;
|
|
if (firstChild && firstChild.nodeType === Node.TEXT_NODE) {
|
|
// Insert after text node
|
|
firstChild.after(sortIndicator);
|
|
} else {
|
|
// Prepend to header
|
|
th.insertBefore(sortIndicator, firstChild);
|
|
}
|
|
|
|
// Click to sort (only add once)
|
|
const handleClick = (e) => {
|
|
// Don't sort if clicking on resizer or filter input
|
|
if (e.target.classList.contains('column-resizer')) return;
|
|
if (e.target.classList.contains('column-filter')) return;
|
|
|
|
const columnName = th.className.replace('col-', '');
|
|
handleSort(columnName, e.ctrlKey || e.metaKey);
|
|
};
|
|
|
|
th.addEventListener('click', handleClick);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Apply default sort (called after initial render)
|
|
*/
|
|
function applyDefaultSort() {
|
|
const files = window.app.modules.store.getDisplayFiles();
|
|
if (files.length > 0) {
|
|
sortState = [{ column: 'original', direction: 'asc' }];
|
|
applySorts();
|
|
updateSortIndicators();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle sort click
|
|
*/
|
|
function handleSort(columnName, multiSort) {
|
|
// Use store to toggle sort
|
|
window.app.modules.store.toggleSort(columnName, multiSort);
|
|
}
|
|
|
|
/**
|
|
* Apply sort to files array (pure function - doesn't mutate)
|
|
*/
|
|
function applySortToFiles(files) {
|
|
if (sortState.length === 0) {
|
|
return files;
|
|
}
|
|
|
|
return [...files].sort((a, b) => {
|
|
for (const sort of sortState) {
|
|
const result = compareValues(a, b, sort.column, sort.direction);
|
|
if (result !== 0) return result;
|
|
}
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Apply all sorts (legacy - triggers render)
|
|
*/
|
|
function applySorts() {
|
|
window.app.modules.spreadsheet.render();
|
|
}
|
|
|
|
/**
|
|
* Apply default sort
|
|
*/
|
|
function applyDefaultSort() {
|
|
const files = window.app.modules.store.getDisplayFiles();
|
|
if (files.length > 0 && sortState.length === 0) {
|
|
sortState = [{ column: 'original', direction: 'asc' }];
|
|
window.app.modules.spreadsheet.render();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current sort state
|
|
*/
|
|
function getSortState() {
|
|
return sortState;
|
|
}
|
|
|
|
/**
|
|
* Update sort indicators in headers
|
|
*/
|
|
function updateIndicators() {
|
|
const table = window.app.dom.spreadsheet;
|
|
const headers = table.querySelectorAll('thead th');
|
|
|
|
headers.forEach(th => {
|
|
const indicator = th.querySelector('.sort-indicator');
|
|
if (!indicator) return;
|
|
|
|
const columnName = th.className.replace('col-', '');
|
|
const sortIndex = sortState.findIndex(s => s.column === columnName);
|
|
|
|
if (sortIndex >= 0) {
|
|
const sort = sortState[sortIndex];
|
|
const arrow = sort.direction === 'asc' ? '▲' : '▼';
|
|
const priority = sortState.length > 1 ? (sortIndex + 1) : '';
|
|
indicator.textContent = ` ${arrow}${priority}`;
|
|
indicator.style.display = 'inline';
|
|
} else {
|
|
indicator.textContent = '';
|
|
indicator.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Compare two values for sorting
|
|
*/
|
|
function compareValues(a, b, columnName, direction) {
|
|
let aVal, bVal;
|
|
|
|
// Get values based on column
|
|
switch (columnName) {
|
|
case 'original':
|
|
aVal = a.originalFilename || '';
|
|
bVal = b.originalFilename || '';
|
|
break;
|
|
case 'extension':
|
|
aVal = a.extension || '';
|
|
bVal = b.extension || '';
|
|
break;
|
|
case 'new':
|
|
case 'newFilename':
|
|
aVal = a.manualFilename || window.app.modules.spreadsheet.computeNewFilename(a, 0);
|
|
bVal = b.manualFilename || window.app.modules.spreadsheet.computeNewFilename(b, 0);
|
|
break;
|
|
case 'trackingNumber':
|
|
aVal = a.trackingNumber || '';
|
|
bVal = b.trackingNumber || '';
|
|
break;
|
|
case 'revision':
|
|
aVal = a.revision || '';
|
|
bVal = b.revision || '';
|
|
break;
|
|
case 'status':
|
|
aVal = a.status || '';
|
|
bVal = b.status || '';
|
|
break;
|
|
case 'title':
|
|
aVal = a.title || '';
|
|
bVal = b.title || '';
|
|
break;
|
|
case 'sha256':
|
|
aVal = a.sha256 || '';
|
|
bVal = b.sha256 || '';
|
|
break;
|
|
default:
|
|
return 0;
|
|
}
|
|
|
|
// Natural sort for strings (handles numbers within strings)
|
|
const comparison = aVal.localeCompare(bVal, undefined, { numeric: true, sensitivity: 'base' });
|
|
|
|
return direction === 'asc' ? comparison : -comparison;
|
|
}
|
|
|
|
/**
|
|
* Clear all sorts
|
|
*/
|
|
function clearSorts() {
|
|
sortState = [];
|
|
applySorts();
|
|
}
|
|
|
|
// Export module
|
|
window.app.modules.sort = {
|
|
init,
|
|
applyDefaultSort,
|
|
applySorts,
|
|
applySortToFiles,
|
|
updateIndicators,
|
|
getSortState,
|
|
clearSorts
|
|
};
|
|
})();
|
|
|
|
/**
|
|
* Excel Integration Module
|
|
* Toast notifications and hash export
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
/**
|
|
* Show toast notification
|
|
*/
|
|
function showToast(message, type = 'info') {
|
|
// Remove existing toast
|
|
const existing = document.querySelector('.toast');
|
|
if (existing) {
|
|
existing.remove();
|
|
}
|
|
|
|
// Create toast
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast toast-${type}`;
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
|
|
// Auto-remove after 5 seconds
|
|
setTimeout(() => {
|
|
toast.classList.add('toast-fade');
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 5000);
|
|
}
|
|
|
|
/**
|
|
* Export SHA256 hashes in sha256sum format
|
|
*/
|
|
async function exportHashes() {
|
|
const files = window.app.modules.store.getDisplayFiles();
|
|
if (files.length === 0) {
|
|
alert('No files to export');
|
|
return;
|
|
}
|
|
|
|
// Check if SHA256 is enabled
|
|
if (!window.app.calculateSha256) {
|
|
alert('Please enable SHA256 checkbox first and wait for hashes to calculate');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Build sha256sum format: hash *filepath
|
|
const lines = [];
|
|
|
|
// Get root path
|
|
const rootPath = await getFullPath(window.app.rootHandle);
|
|
|
|
for (const file of files) {
|
|
if (!file.sha256 || file.sha256 === 'calculating...' || file.sha256 === 'error') {
|
|
continue; // Skip files without calculated hash
|
|
}
|
|
|
|
// Get full path from root
|
|
const folderPath = await getFullPath(file.folderHandle);
|
|
const fullPath = `${folderPath}/${zddc.joinExtension(file.originalFilename, file.extension)}`;
|
|
|
|
// Format: hash *filepath (asterisk indicates binary mode)
|
|
lines.push(`${file.sha256} *${fullPath}`);
|
|
}
|
|
|
|
if (lines.length === 0) {
|
|
alert('No SHA256 hashes available. Enable SHA256 and wait for calculation to complete.');
|
|
return;
|
|
}
|
|
|
|
// Create output
|
|
const output = lines.join('\n');
|
|
|
|
// Generate filename with timestamp
|
|
const now = new Date();
|
|
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, -5); // YYYY-MM-DDTHH-MM-SS
|
|
const filename = `sha256sums_${timestamp}.txt`;
|
|
|
|
// Download as file
|
|
const blob = new Blob([output], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
// Show success message
|
|
showToast(`✓ Downloaded ${lines.length} hash(es) to ${filename}`, 'success');
|
|
|
|
} catch (err) {
|
|
console.error('Error exporting hashes:', err);
|
|
alert('Error exporting hashes: ' + err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get full path from directory handle (all the way to root)
|
|
*/
|
|
async function getFullPath(dirHandle) {
|
|
const parts = [];
|
|
let current = dirHandle;
|
|
|
|
// Walk up to root - collect ALL parent folders
|
|
while (current) {
|
|
parts.unshift(current.name);
|
|
|
|
try {
|
|
// Try to get parent
|
|
if (typeof current.getParent === 'function') {
|
|
const parent = await current.getParent();
|
|
if (parent && parent !== current) {
|
|
current = parent;
|
|
continue;
|
|
}
|
|
}
|
|
break;
|
|
} catch {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return parts.join('/');
|
|
}
|
|
|
|
// Export module
|
|
window.app.modules.excel = {
|
|
showToast,
|
|
exportHashes
|
|
};
|
|
})();
|
|
|
|
/**
|
|
* ZDDC shared help panel — open/close logic.
|
|
* Works with all four tools regardless of their module pattern.
|
|
* Expects: #help-btn, #help-panel, #help-panel-close in the DOM.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
function init() {
|
|
var helpBtn = document.getElementById('help-btn');
|
|
var panel = document.getElementById('help-panel');
|
|
var closeBtn = document.getElementById('help-panel-close');
|
|
|
|
if (!helpBtn || !panel) { return; }
|
|
|
|
function isOpen() { return !panel.hidden; }
|
|
|
|
function openPanel() {
|
|
panel.hidden = false;
|
|
document.body.classList.add('help-open');
|
|
}
|
|
|
|
function closePanel() {
|
|
panel.hidden = true;
|
|
document.body.classList.remove('help-open');
|
|
}
|
|
|
|
helpBtn.addEventListener('click', function () {
|
|
if (isOpen()) { closePanel(); } else { openPanel(); }
|
|
});
|
|
|
|
if (closeBtn) {
|
|
closeBtn.addEventListener('click', closePanel);
|
|
}
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape' && isOpen()) { closePanel(); }
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
}());
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|