Three things on the public website:
1) Cut alpha and beta channel builds for all five tools, so each tool
now has stable + beta + alpha actually published — previously
beta and alpha were vapor for archive (which had been freshened
earlier) and missing entirely for the others. The intro page's
tool cards now point at real artifacts on every channel.
2) New website/releases/index.html — a generated index of every
version + channel of every tool, with stable/beta/alpha pill
links per tool and a "Pin to version" row of every concrete
v0.0.X build. Regenerated by build.sh's new build_releases_index
function (reads the filesystem so it is always consistent with
what is actually under releases/). Linked from the intro page nav
(Releases), from the bottom of the Try the tools section
("Browse all versions"), and from the Learn more list.
reference.html's nav gets the same Releases link.
3) Folded website/zddc-server.html into website/index.html as a new
inline section ("zddc-server (optional)") below the tool cards.
The earlier separate page is removed; the broken Server nav link
that pointed at it is gone too. The new section leads with the
dual-mode insight (the tools work locally on a folder OR via any
web server, including the optional zddc-server) and frames
zddc-server as a small Go binary that adds things a generic web
server cannot: ACL via .zddc files, virtual .archive URL space,
per-request access logging, mundane glue. The What is it?
paragraph also mentions the dual-mode story up front so users
reading top-to-bottom get the framing before they hit the cards.
Also caught two stale _latest.html refs missed by the earlier
rename sweep: 8 tool links in reference.html and a comment line in
CLAUDE.md. Verified with a full link audit — every relative href in
index.html, reference.html, and releases/index.html now resolves to
an existing file under website/.
ARCHITECTURE.md doc-ownership table updated: zddc-server.html row
removed; new row added for the regenerated releases/index.html.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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"><span style="color:red;font-weight:bold">alpha · 2026-04-28 · 67f794e</span></span>
|
|
</div>
|
|
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
|
|
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>
|
|
</div>
|
|
<div class="header-right">
|
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
|
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main Content -->
|
|
<div class="main-content">
|
|
<!-- Folder Tree -->
|
|
<aside class="folder-tree-pane" id="folderTreePane">
|
|
<div class="pane-header">
|
|
<div class="pane-header-title">
|
|
<button class="btn btn-sm collapse-tree-btn" id="collapseTreeBtn" title="Collapse folder tree">◀</button>
|
|
<h3>Folder Tree</h3>
|
|
</div>
|
|
<div class="pane-header-controls">
|
|
<label class="checkbox-label" title="Auto-scroll folder tree when hovering files">
|
|
<input type="checkbox" id="autoScrollCheckbox" checked>
|
|
Auto-scroll
|
|
</label>
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="hideCompliantCheckbox">
|
|
Hide Compliant
|
|
</label>
|
|
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
|
|
</div>
|
|
</div>
|
|
<div id="folderTree" class="folder-tree">
|
|
<!-- Dynamically populated -->
|
|
</div>
|
|
<div class="resize-handle" id="treeResizeHandle"></div>
|
|
</aside>
|
|
|
|
<!-- Spreadsheet Table -->
|
|
<main class="spreadsheet-pane">
|
|
<div class="pane-header">
|
|
<div class="pane-header-left">
|
|
<h3>Files</h3>
|
|
<div class="file-stats">
|
|
<span id="totalFiles">0 files</span>
|
|
<span id="modifiedFiles">0 modified</span>
|
|
<span id="errorFiles" class="hidden">0 errors</span>
|
|
</div>
|
|
</div>
|
|
<div class="pane-header-right">
|
|
<button id="saveAllBtn" class="btn btn-success btn-sm" disabled>Save All</button>
|
|
<button id="cancelAllBtn" class="btn btn-secondary btn-sm" disabled>Cancel All</button>
|
|
<span class="header-divider">|</span>
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="sha256Checkbox">
|
|
SHA256
|
|
</label>
|
|
<button id="exportHashesBtn" class="btn btn-secondary btn-sm" disabled title="Export SHA256 hashes in sha256sum format">💾 Export Hashes</button>
|
|
<span class="header-divider">|</span>
|
|
<button id="togglePreviewBtn" class="btn btn-secondary btn-sm" title="Toggle file preview panel">👁 Preview</button>
|
|
</div>
|
|
</div>
|
|
<div class="spreadsheet-container">
|
|
<table id="spreadsheet" class="spreadsheet">
|
|
<thead>
|
|
<tr>
|
|
<th class="col-row-num">#</th>
|
|
<th class="col-original">Original Filename
|
|
<input type="text" class="column-filter" data-filter-field="original" placeholder="filter…" spellcheck="false" aria-label="Filter by original filename">
|
|
</th>
|
|
<th class="col-extension">Ext
|
|
<input type="text" class="column-filter" data-filter-field="extension" placeholder="filter…" spellcheck="false" aria-label="Filter by extension">
|
|
</th>
|
|
<th class="col-new">New Filename
|
|
<input type="text" class="column-filter" data-filter-field="newFilename" placeholder="filter…" spellcheck="false" aria-label="Filter by new filename">
|
|
</th>
|
|
<th class="col-trackingNumber">Tracking
|
|
<input type="text" class="column-filter" data-filter-field="trackingNumber" placeholder="filter…" spellcheck="false" aria-label="Filter by tracking number">
|
|
</th>
|
|
<th class="col-revision">Rev
|
|
<input type="text" class="column-filter" data-filter-field="revision" placeholder="filter…" spellcheck="false" aria-label="Filter by revision">
|
|
</th>
|
|
<th class="col-status">Status
|
|
<input type="text" class="column-filter" data-filter-field="status" placeholder="filter…" spellcheck="false" aria-label="Filter by status">
|
|
</th>
|
|
<th class="col-title">Title
|
|
<input type="text" class="column-filter" data-filter-field="title" placeholder="filter…" spellcheck="false" aria-label="Filter by title">
|
|
</th>
|
|
<th class="col-sha256 hidden" id="sha256Column">SHA256
|
|
<input type="text" class="column-filter" data-filter-field="sha256" placeholder="filter…" spellcheck="false" aria-label="Filter by SHA256">
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="spreadsheetBody">
|
|
<!-- Dynamically populated -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</main>
|
|
|
|
</div>
|
|
|
|
<!-- Empty State — shown until a directory is selected -->
|
|
<div id="welcomeScreen" class="empty-state">
|
|
<div class="empty-state-content">
|
|
<h2>ZDDC Classifier</h2>
|
|
<p>Rename a folder of files to ZDDC format using a spreadsheet interface.</p>
|
|
<p>Open a directory, fill in tracking number, revision, status, and title for each file, then save — the files are renamed on disk.</p>
|
|
|
|
<!-- Browser Compatibility Warning -->
|
|
<div id="browserWarning" class="browser-warning hidden">
|
|
<h3>⚠️ Browser Not Supported</h3>
|
|
<p>This application requires the File System Access API, available only in Chromium-based browsers (Chrome, Edge, Brave, Opera).</p>
|
|
</div>
|
|
|
|
<ul class="welcome-list">
|
|
<li>Files already named to ZDDC format are parsed automatically</li>
|
|
<li>Edit cells directly, or copy columns to and from Excel</li>
|
|
<li>Real-time validation highlights non-compliant names</li>
|
|
<li>Rename one file or all modified files at once</li>
|
|
</ul>
|
|
|
|
<p>Click <strong>Select Directory</strong> to begin.</p>
|
|
|
|
<p class="note">This application works entirely in your browser. No data is transmitted to any server.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Help Panel -->
|
|
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
|
<div class="help-panel__header">
|
|
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Classifier</h2>
|
|
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">×</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>
|