Initial commit
ZDDC — Zero Day Document Control. A file-naming convention plus five single-file HTML tools (archive, transmittal, classifier, mdedit, landing) and an optional Go HTTP server (zddc-server) with ACL and a virtual archive index. Self-contained, offline-capable, dependency-free. See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the build/release/architecture detail, bootstrap/README.md for the two-level deployment install pattern, and zddc/README.md for the HTTP server.
This commit is contained in:
commit
ea385b5366
208 changed files with 75161 additions and 0 deletions
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
tossme/
|
||||
examples/
|
||||
.env
|
||||
.vscode
|
||||
|
||||
# Session planning (never public)
|
||||
PLAN.md
|
||||
|
||||
# Node dependencies
|
||||
node_modules/
|
||||
|
||||
# Test report and results
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Build artifacts
|
||||
# NOTE: dist/ is listed here but each tool's dist/*.html is force-tracked in git
|
||||
# (added with `git add -f tool/dist/tool.html`). This is intentional — built artifacts
|
||||
# are committed alongside source so users can download them directly from the repo.
|
||||
# New tool dist files must be force-added: git add -f tool/dist/tool.html
|
||||
dist/
|
||||
|
||||
# IDE and project files
|
||||
.opencode/
|
||||
opencode.json
|
||||
package-lock.json
|
||||
zddc-knowledge*.json
|
||||
zddc-knowledge*.md
|
||||
zddc-knowledge*.html
|
||||
264
AGENTS.md
Normal file
264
AGENTS.md
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
# AGENTS.md — ZDDC
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Build all tools (writes to dist/ only)
|
||||
sh build.sh
|
||||
|
||||
# Build single tool
|
||||
sh tool/build.sh # archive | transmittal | classifier | mdedit | landing
|
||||
|
||||
# Cut a stable release (auto-increments patch version, tags, writes to website/releases/)
|
||||
sh tool/build.sh --release
|
||||
sh tool/build.sh --release 1.2.0 # explicit version
|
||||
|
||||
# Cut an alpha/beta channel build (mutable, no git tag)
|
||||
sh tool/build.sh --release alpha
|
||||
sh tool/build.sh --release beta
|
||||
|
||||
# Release all tools at once
|
||||
sh build.sh --release [version|alpha|beta]
|
||||
|
||||
# Test all tools
|
||||
npm test
|
||||
|
||||
# Test single tool
|
||||
npx playwright test tool # archive | transmittal | classifier | mdedit
|
||||
|
||||
# Dev server (cache-busting HTTP, on port 8000)
|
||||
./dev-server start
|
||||
./dev-server stop
|
||||
```
|
||||
|
||||
No lint, typecheck, or format commands exist — the project is plain sh + vanilla JS.
|
||||
|
||||
## Architecture
|
||||
|
||||
Five independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — the first four name their output `dist/tool.html`; `landing` writes `dist/index.html` (it's served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below.
|
||||
|
||||
```
|
||||
tool/
|
||||
css/ source stylesheets (concatenated in order)
|
||||
js/ vanilla JS IIFEs (concatenated in order)
|
||||
template.html placeholder markers: {{CSS_PLACEHOLDER}}, {{JS_PLACEHOLDER}}, {{BUILD_LABEL}}
|
||||
build.sh assembles dist/tool.html
|
||||
dist/tool.html generated output — committed with `git add -f`
|
||||
|
||||
shared/
|
||||
base.css CSS tokens and primitives included first by every tool's CSS build
|
||||
zddc.js canonical filename/folder/revision parsers, formatters, status validation
|
||||
zddc-filter.js shared ZDDC project/status filter UI module
|
||||
theme.js light/dark theme switcher
|
||||
help.js shared help dialog module
|
||||
build-lib.sh POSIX sh helpers (ensure_exists, concat_files, build_timestamp)
|
||||
sourced by every tool's build.sh via: . "$root_dir/../shared/build-lib.sh"
|
||||
|
||||
website/
|
||||
index.html current stable landing (root URL)
|
||||
releases/
|
||||
<tool>_v<X>.<Y>.<Z>.html immutable stable release archives
|
||||
<tool>_latest.html -> ... symlink to current stable (highest semver)
|
||||
<tool>_alpha.html mutable; overwritten by --release alpha
|
||||
<tool>_beta.html mutable; overwritten by --release beta
|
||||
install.zip drop-in self-contained install (5 stable HTMLs + _template/ stubs)
|
||||
track-latest.zip level-2 stubs that track the current-stable channel
|
||||
track-alpha.zip level-2 stubs that track the alpha channel
|
||||
track-beta.zip level-2 stubs that track the beta channel
|
||||
|
||||
bootstrap/
|
||||
level1.html.tmpl per-project bootstrap template (relative ../<tool>.html)
|
||||
level2.html.tmpl level-2 channel-tracking bootstrap template
|
||||
README.md install / channel / pin docs
|
||||
```
|
||||
|
||||
**Critical:** `dist/` files are gitignored but force-committed (`git add -f`). Never edit them directly.
|
||||
|
||||
## Shared CSS (`shared/base.css`)
|
||||
|
||||
Included as the **first** positional arg to every tool's `concat_files` CSS call. Provides:
|
||||
- `:root` CSS custom properties — `--primary`, `--bg`, `--text`, `--border`, `--font`, etc.
|
||||
- Brand color: `--primary: #2a5a8a` (matches zddc.varasys.io)
|
||||
- Button primitive: `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-sm`, `.btn-lg`, `.btn-link`
|
||||
- `.app-header` + `.app-header__title` chrome rules
|
||||
- `.build-timestamp`, `.hidden`, `.truncate`, webkit scrollbars
|
||||
|
||||
**Do not** define these in any tool's own CSS — they come from shared.
|
||||
|
||||
**Toast CSS** lives in `classifier/css/base.css` only (classifier is the only tool that uses toasts).
|
||||
|
||||
## Transmittal CSS quirks
|
||||
|
||||
- `transmittal/css/base.css` overrides `html { font-size: 16px }` inside `@media screen` — this must stay. `shared/base.css` sets `14px`; transmittal's floating labels are rem-based and were designed for 16px.
|
||||
- The floating label position is defined in `transmittal/css/forms.css`, not Tailwind classes. If adding new Tailwind classes to `template.html`, add them to `transmittal/css/utilities.css` too — there is no Tailwind build step.
|
||||
|
||||
## Build system rules
|
||||
|
||||
- Every `build.sh` sources `shared/build-lib.sh` first (provides `ensure_exists`, `concat_files`, `build_timestamp`). Set `root_dir` before sourcing.
|
||||
- Build scripts use **POSIX sh** (`#!/bin/sh` with `set -eu`), not bash.
|
||||
- `concat_files` accepts **positional args only** (not array names).
|
||||
- `awk` processes `template.html`, replacing `{{PLACEHOLDER}}` markers and stripping CDN `<script>`/`<link>` tags (pattern: `https?://`)
|
||||
- `{{BUILD_LABEL}}` is substituted in all five tools via `gsub` in awk (use `gsub`, not `print` — the placeholder is inline in an HTML line). Value is `Built: <timestamp> BETA` for dev builds, `v<version>` for stable releases, and `<channel> · <date> · <sha>` for alpha/beta channel builds; computed before the awk step. The shared `is_red` flag controls whether the label is wrapped in a red+bold `<span>` (true for dev/alpha/beta, false for stable).
|
||||
- Cleans up temp files via `trap cleanup EXIT`
|
||||
|
||||
**`</` escaping is mandatory.** Any JS containing `</tag>` inside string or template literals will break inline `<script>` embedding. Run:
|
||||
```bash
|
||||
sed 's#</#<\\/#g' "$input_js" > "$safe_js"
|
||||
```
|
||||
Required for any new tool with vendor JS or JS containing HTML template literals.
|
||||
|
||||
## JS module pattern
|
||||
|
||||
All JS is vanilla, no bundlers. Files are IIFEs, registered on `window.app.modules`. Load order = declaration order in `build.sh`. `window.app` is the only global.
|
||||
|
||||
```javascript
|
||||
(function() {
|
||||
window.app.modules.mymodule = { ... };
|
||||
})();
|
||||
```
|
||||
|
||||
**Exception:** archive uses plain globals (`APP_STATE`, top-level functions) — not the IIFE/modules pattern.
|
||||
|
||||
## ZDDC filename parsers
|
||||
|
||||
All parsing/formatting goes through `shared/zddc.js`, exposed as `window.zddc`. Tools call it directly — no per-tool wrappers.
|
||||
|
||||
`window.zddc` exports:
|
||||
- `parseFilename(name)` → `{ trackingNumber, revision, status, title, extension, valid } | null` (extension WITHOUT leading dot)
|
||||
- `parseFolder(name)` → `{ date, trackingNumber, status, title, valid } | null`
|
||||
- `parseRevision(rev)` → `{ base, modifier, modifierType, modifierNumber, isDraft, modifierIsDraft, full }`
|
||||
- `compareRevisions(a, b)` → number (canonical sort order)
|
||||
- `formatFilename(parts)` / `formatFolder(parts)` — round-trips parsed output
|
||||
- `isValidStatus(code)` — accepts known status codes plus `---`
|
||||
|
||||
All file objects across tools use `file.trackingNumber` (string) and `file.extension` (string, **no leading dot**, e.g. `'pdf'` not `'.pdf'`). When concatenating into a filename, write `name + '.' + ext`.
|
||||
|
||||
Coverage lives in `tests/zddc.spec.js` (47 cases). Add new edge cases there, not in tool tests.
|
||||
|
||||
## Testing quirks
|
||||
|
||||
- Playwright + Chromium only (File System Access API requirement)
|
||||
- Tests open `dist/tool.html` via `file://` protocol — **always build before testing**
|
||||
- File System Access API is mocked via `page.addInitScript()` using `tests/fixtures/mock-fs-api.js`
|
||||
- Use `waitUntil: 'load'` or `'domcontentloaded'` not `'networkidle'` — bundled scripts keep the network "active"
|
||||
- Archive's `#noDirectoryMessage` empty-state overlay is `position: absolute; top: 50px` — it must clear the header or it will block button clicks in tests
|
||||
|
||||
## ZDDC filename convention
|
||||
|
||||
Format: `trackingNumber_revision (status) - title.extension`
|
||||
|
||||
- `trackingNumber`: no spaces or underscores (e.g. `123456-EL-SPC-2623`)
|
||||
- `revision`: `A`, `B`, `0`; draft prefix `~`; modifiers `+C1`, `+B1`, `+N1`, `+Q1`
|
||||
- `status`: `IFA IFB IFC IFD IFI IFP IFR IFU REC RSA RSB RSC RSD RSI` or `---`
|
||||
- Folder names prefix with date: `2025-10-31_trackingNumber (status) - title`
|
||||
|
||||
## Git workflow
|
||||
|
||||
- Feature-branch workflow; squash-merge feature branches to `main`
|
||||
- Conventional commits: `feat(archive): ...`, `fix(transmittal): ...`
|
||||
- Release tags: `archive-v1.0.0` (per-tool semver)
|
||||
- Commit dist files: `git add -f tool/dist/tool.html`
|
||||
|
||||
### Releasing — channels and layout
|
||||
|
||||
Three channels:
|
||||
|
||||
- **Stable**: versioned, immutable. `sh tool/build.sh --release [version]` writes `website/releases/<tool>_v<version>.html`, refreshes the `<tool>_latest.html` symlink, and tags `<tool>-v<version>`. Skips automatically if source has not changed since the latest tag. Pass an explicit version to override auto-increment.
|
||||
- **Beta**: mutable. `sh tool/build.sh --release beta` overwrites `website/releases/<tool>_beta.html` in place. No tag. The on-page label is `beta · <date> · <sha>` so the source is recoverable from git via the SHA.
|
||||
- **Alpha**: mutable, analogous. `sh tool/build.sh --release alpha`.
|
||||
|
||||
Stable releases do **not** automatically clobber `<tool>_alpha.html` / `<tool>_beta.html` — those keep whatever was last built into them. To freshen alpha to current stable, `git checkout v<X>.<Y>.<Z> && sh tool/build.sh --release alpha`.
|
||||
|
||||
After cutting a stable release, run `git push --tags` to publish the tag.
|
||||
|
||||
The "skip if no source change since last tag" guard for stable releases compares **HEAD** to the latest tag — uncommitted working-tree changes are invisible. If you edit a tool and want a stable release to actually fire, commit the change first; otherwise the build prints `no source changes since <tool>-vX.Y.Z — skipping` and exits 0. Alpha and beta channel builds always rebuild (no skip check).
|
||||
|
||||
Agents must **never** write to `website/releases/` or `website/index.html` directly — always go through `--release`.
|
||||
|
||||
`landing/build.sh --release <version>` additionally writes `website/index.html` (the root URL of zddc.varasys.io).
|
||||
|
||||
### Bootstrap zips
|
||||
|
||||
`build.sh` regenerates three downloadable zips into `website/` on every invocation:
|
||||
|
||||
- `install.zip` — 5 current-stable HTMLs at root + `_template/` directory containing 4 level-1 bootstrap stubs (per-project use). Skipped if any tool has no stable release yet.
|
||||
- `track-{alpha,beta,latest}.zip` — 5 level-2 stubs each, hardcoded to fetch the named channel from `zddc.varasys.io/releases/`. Drop one over a deployment root to switch the whole site to that channel.
|
||||
|
||||
See `bootstrap/README.md` for the install / pin / audit story.
|
||||
|
||||
### Worktrees
|
||||
|
||||
Use `git worktree` to run multiple agents on separate branches simultaneously without filesystem collisions.
|
||||
|
||||
- Worktrees live at `~/src/zddc-<branch-name>` (sibling of the main clone)
|
||||
- Before starting work on a feature branch, check `git worktree list`; if no worktree exists, create one: `git worktree add ~/src/zddc-<branch-name> -b <branch-name>`
|
||||
- All edits, builds (`sh build.sh`), and tests (`npm test`) run from within the worktree directory — build scripts use relative paths so this works correctly
|
||||
- The `dist/` force-commit rule (`git add -f`) applies per-worktree
|
||||
- After the branch is merged, clean up: `git worktree remove ~/src/zddc-<branch-name>` then delete the branch
|
||||
- Never run `git checkout` or `git switch` inside a worktree that another agent may be using
|
||||
|
||||
## Transmittal-specific
|
||||
|
||||
- Two-phase hydration: `populateStatic()` before publish, `hydrate()` on load of published file
|
||||
- Reactive state via Proxy — `app.state.mode = 'view'` auto-notifies subscribers
|
||||
- Runtime CDN loads (jszip, docx-preview, xlsx) are allowed only for the optional DOCX/XLSX preview; core features work offline
|
||||
- Published payload stored in `<script id="transmittal-data" type="application/json">`
|
||||
|
||||
## mdedit-specific
|
||||
|
||||
- `css/tailwind-utils.css` is a pre-generated static subset (~80 classes). Add new Tailwind classes here; do not re-run Tailwind.
|
||||
- Toast UI Editor v3.2.2 is bundled in `vendor/`; `template.html` loads it from CDN for dev convenience
|
||||
- `</` escaping is essential: `sed 's#</#<\\/#g'` runs on both app JS and vendor JS at build time
|
||||
|
||||
## Training-data
|
||||
|
||||
The `training-data/` directory scripts and README are committed. The data itself is excluded via `training-data/.gitignore`:
|
||||
|
||||
- `raw/` — raw interaction logs (never committed)
|
||||
- `processed/`, `validation/`, `adapters/`, `snapshots/` — generated data (never committed)
|
||||
|
||||
## zddc-server
|
||||
|
||||
Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --browse` for ZDDC archives.
|
||||
|
||||
### Build
|
||||
|
||||
```sh
|
||||
# Build the container image (from the zddc/ directory)
|
||||
podman build -t zddc-server zddc/
|
||||
|
||||
# Or inside the zddc/ directory:
|
||||
podman build -t zddc-server .
|
||||
```
|
||||
|
||||
### Run (development)
|
||||
|
||||
```sh
|
||||
ZDDC_DATA_DIR=/path/to/your/archive podman-compose -f zddc/podman-compose.yaml up --build
|
||||
```
|
||||
|
||||
### Key environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `ZDDC_ROOT` | *(required)* | Path to served file tree |
|
||||
| `ZDDC_ADDR` | `:8443` | Bind address |
|
||||
| `ZDDC_EMAIL_HEADER` | `X-Email` | Header set by upstream proxy with user email |
|
||||
| `ZDDC_INDEX_PATH` | `.archive` | Virtual archive index URL segment |
|
||||
| `ZDDC_LOG_LEVEL` | `info` | Logging verbosity |
|
||||
| `ZDDC_CORS_ORIGIN` | `https://zddc.varasys.io` | Comma-separated CORS allowlist; empty value disables CORS. Default lets tools served from zddc.varasys.io call back into a customer-deployed server. |
|
||||
|
||||
### Release tagging
|
||||
|
||||
```sh
|
||||
git tag zddc-server-v1.0.0
|
||||
git push --tags
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/` (requires Go 1.24+)
|
||||
- The container image does NOT require Go on the host — the Containerfile uses a multi-stage build
|
||||
- Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories
|
||||
- The `.archive` virtual path resolves ZDDC tracking numbers to their earliest-received revision
|
||||
- ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Email` header
|
||||
476
ARCHITECTURE.md
Normal file
476
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
# ZDDC Architecture
|
||||
|
||||
This document is the single authoritative reference for how ZDDC tools are designed and built. It covers the shared single-file HTML application pattern, the build system, tool-specific architectural decisions, and contribution guidelines.
|
||||
|
||||
---
|
||||
|
||||
## Why Single-File HTML Applications
|
||||
|
||||
Every ZDDC tool compiles to a single self-contained `.html` file — no servers, no installers, no subscriptions.
|
||||
|
||||
| Principle | Rationale |
|
||||
|-----------|-----------|
|
||||
| **Reliability** | Opens in any modern Chromium-based browser without network access or external services |
|
||||
| **Portability** | Can be emailed, archived, or deployed to air-gapped environments with no tooling |
|
||||
| **Auditability** | Source, embedded data, and output travel together, satisfying ZDDC traceability requirements |
|
||||
| **Longevity** | Static assets remain functional long after build environments have changed |
|
||||
| **Simplicity** | A single `.html` file eliminates deployment steps and brittle dependency chains |
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
Every HTML tool follows the same directory layout:
|
||||
|
||||
```
|
||||
tool/
|
||||
README.md # Feature scope, UI design, domain rules, help content
|
||||
css/ # Logically separated stylesheets (one responsibility per file)
|
||||
js/ # Vanilla ES modules (one responsibility per file)
|
||||
template.html # Shell markup with {{PLACEHOLDER}} markers for development
|
||||
build.sh # Inlines css/ and js/ into dist/tool.html
|
||||
dist/
|
||||
tool.html # Generated output — never edit this manually
|
||||
```
|
||||
|
||||
Website files (what `zddc.varasys.io` serves) are organized by channel:
|
||||
|
||||
```
|
||||
website/
|
||||
index.html # current stable landing tool (root URL)
|
||||
releases/
|
||||
<tool>_v<X>.<Y>.<Z>.html # immutable stable release archives
|
||||
<tool>_latest.html -> ... # symlink to the highest-versioned stable
|
||||
<tool>_alpha.html # mutable: overwritten on every --release alpha
|
||||
<tool>_beta.html # mutable: overwritten on every --release beta
|
||||
install.zip # current-stable HTMLs + project bootstrap stubs
|
||||
track-{alpha,beta,latest}.zip # level-2 channel-tracking stubs
|
||||
```
|
||||
|
||||
There is no `website/dev/`. To preview a build locally, open `dist/tool.html` directly via the dev server. To publish on `zddc.varasys.io`, cut a release.
|
||||
|
||||
Vendor dependencies (bundled third-party libraries) live in `tool/vendor/` if present. The build script is responsible for inlining them into the output.
|
||||
|
||||
---
|
||||
|
||||
## Documentation ownership
|
||||
|
||||
Each topic has exactly one authoritative home; everything else links to it.
|
||||
|
||||
| Topic | Single home | Linked from |
|
||||
|---|---|---|
|
||||
| What ZDDC is + tool channel links + install bundles | `website/index.html` (hand-edited intro for `zddc.varasys.io/`) | repo `README.md`, `bootstrap/README.md` |
|
||||
| File-naming convention spec (status codes, modifiers, folder format) | `website/reference.html` | repo `README.md`, in-tool help text |
|
||||
| Customer-deployment install (install.zip, level-1/2 stubs, `?v=`, audit) | `bootstrap/README.md` | website intro, `zddc/README.md` |
|
||||
| zddc-server config, ACL, `.archive`, deployment | `zddc/README.md` | `AGENTS.md`, `bootstrap/README.md` |
|
||||
| Build / release / channel commands | `AGENTS.md` | repo `README.md` ("see AGENTS.md") |
|
||||
| Architecture & internal patterns | `ARCHITECTURE.md` (this file) | `AGENTS.md` |
|
||||
| Per-tool internal design quirks | `<tool>/README.md` | (linked from website intro tool cards) |
|
||||
|
||||
`website/index.html` is **hand-edited static content** (analogous to `reference.html`), not the landing-tool output. The landing tool ships only via `website/releases/landing_v<X>.html` and `install.zip` — `install.zip` copies `landing_latest.html` to `<deployment-root>/index.html` for customer sites where the project picker UI is actually useful (it queries `zddc-server` for the project list). The public website at `zddc.varasys.io/` has nothing to pick, so its root URL is the introduction page.
|
||||
|
||||
When updating documentation, prefer linking over duplicating. If you find yourself rewriting the file-naming convention in a tool's README, link to `reference.html` instead.
|
||||
|
||||
---
|
||||
|
||||
## Build System
|
||||
|
||||
### How It Works
|
||||
|
||||
Each tool's `build.sh`:
|
||||
|
||||
1. Reads CSS files in declaration order, concatenates them
|
||||
2. Reads JS files in declaration order, concatenates them
|
||||
3. Processes `template.html` with `awk`, replacing `{{PLACEHOLDER}}` markers with the concatenated content and stripping CDN `<script>`/`<link>` tags
|
||||
4. Writes the result to `dist/tool.html`
|
||||
5. If `--release <channel-or-version>` was passed, calls `promote_release` to write the appropriate file under `website/releases/`
|
||||
|
||||
The top-level `build.sh` at the repository root calls all five tool build scripts in sequence and then regenerates the bootstrap zips (`install.zip`, `track-{alpha,beta,latest}.zip`) so they always match what's in `releases/`.
|
||||
|
||||
### Channels
|
||||
|
||||
Three release channels:
|
||||
|
||||
- **Stable** — versioned, immutable. `--release [version]` writes `website/releases/<tool>_v<version>.html`, refreshes the `<tool>_latest.html` symlink, and tags `<tool>-v<version>` in git. Skips automatically when there is no source change since the last tag.
|
||||
- **Beta** — mutable. `--release beta` overwrites `website/releases/<tool>_beta.html` in place. No git tag; the on-page label is `beta · <date> · <sha>` so the source is recoverable from git history via the SHA.
|
||||
- **Alpha** — mutable, analogous to beta.
|
||||
|
||||
Stable releases do not automatically clobber `<tool>_alpha.html` / `<tool>_beta.html` — those keep whatever was last built into them. To freshen alpha to current stable, `git checkout v<X>.<Y>.<Z> && sh tool/build.sh --release alpha`.
|
||||
|
||||
The on-page `{{BUILD_LABEL}}` is rendered red+bold for dev/alpha/beta builds (`is_red=1`) and black for stable releases. The label format is:
|
||||
|
||||
| Build | Label |
|
||||
|---------------|---------------------------------------------|
|
||||
| dev | `Built: 2026-04-27 14:00:00 BETA` |
|
||||
| alpha | `alpha · 2026-04-27 · abc1234` |
|
||||
| beta | `beta · 2026-04-27 · abc1234` |
|
||||
| stable | `v0.0.5` |
|
||||
|
||||
### Two-level bootstrap
|
||||
|
||||
Customer deployments under `zddc-server` use a two-level bootstrap pattern that keeps tool installation decoupled from publishing. See `bootstrap/README.md` for the full story; in short:
|
||||
|
||||
- **Level 1**: per-project stub at `<project>/<tool>.html` that fetches `../<tool>.html` (always same-origin). One file per project per tool, never edited after install.
|
||||
- **Level 2** (optional): site admin replaces `<deployment-root>/<tool>.html` with a stub fetching `https://zddc.varasys.io/releases/<tool>_<channel>.html` — switches the whole site to a channel. Without it, `<deployment-root>/<tool>.html` is just the actual built tool HTML (self-contained install).
|
||||
|
||||
`document.write()` chains across both levels; origin stays at the deployment domain throughout. CORS only matters at level 2 (cross-origin to `zddc.varasys.io`); level 1 is same-origin.
|
||||
|
||||
The stubs are generated from `bootstrap/level{1,2}.html.tmpl` by the root `build.sh` and packaged into `install.zip` and `track-<channel>.zip`.
|
||||
|
||||
### Build Script Requirements
|
||||
|
||||
Every `build.sh` must:
|
||||
|
||||
- Begin with `#!/bin/sh` and `set -eu` (POSIX sh, not bash)
|
||||
- Source `shared/build-lib.sh` first (provides `ensure_exists`, `concat_files`, `build_timestamp`, `compute_build_label`, `promote_release`)
|
||||
- Fail immediately on missing source files (`ensure_exists` pattern)
|
||||
- Clean up temp files on exit (use `trap cleanup EXIT`)
|
||||
- Accept `--release [<version>|alpha|beta]` — explicit version or channel name; otherwise produce a dev build
|
||||
|
||||
### HTML Embedding Safety
|
||||
|
||||
When inlining JavaScript into a `<script>` block, the HTML parser scans for the exact string `</script>` to terminate the block — backslash escaping (`<\/script>`) does **not** prevent termination. Any JS source file or vendor library that contains `</tag>` sequences inside string literals or template literals will break the inline `<script>` block.
|
||||
|
||||
The rule is:
|
||||
|
||||
> **All `</` sequences in inlined JavaScript must be escaped as `<\/` using `sed`.**
|
||||
|
||||
Both the app JS concatenation step and any vendor JS bundling step must run through:
|
||||
|
||||
```bash
|
||||
sed 's#</#<\\/#g' "$input_js" > "$safe_js"
|
||||
```
|
||||
|
||||
Then use `</script>` (not `<\/script>`) to close the `<script>` block, since the content no longer contains any `</` sequences that the parser could misread.
|
||||
|
||||
This is already enforced for mdedit's vendor bundling. It is the contributor's responsibility to ensure new tools follow this pattern.
|
||||
|
||||
### Vendor Dependencies
|
||||
|
||||
Some tools bundle third-party libraries. These live in `tool/vendor/` and are committed to the repository. The build script inlines them into `dist/tool.html`.
|
||||
|
||||
**Current vendor files:**
|
||||
|
||||
| Tool | Library | File | Notes |
|
||||
|------|---------|------|-------|
|
||||
| mdedit | Toast UI Editor v3.2.2 | `vendor/toastui-editor-all.min.js` | Markdown editor with live preview |
|
||||
| mdedit | Toast UI Editor CSS | `vendor/toastui-editor.min.css` | Editor stylesheet |
|
||||
| transmittal | jszip, docx-preview, xlsx | CDN at runtime | Optional preview features; tool works without them |
|
||||
|
||||
**Runtime CDN loading exception**: The transmittal tool loads jszip, docx-preview, and xlsx from CDN at runtime via `loadLibrary()` forDOCX/XLSX preview functionality. These are **optional enhancements**—core transmittal functionality (JSON payload communication) works without them. This exception is documented here because:
|
||||
|
||||
1. The core transmittal features (creating, signing, verifying SHA-256 digests) do not depend on these libraries
|
||||
2. Preview functionality gracefully degrades if libraries fail to load
|
||||
3. Bundling would significantly increase file size for rarely-used features
|
||||
|
||||
**Rule**: Runtime CDN loading is allowed only when:
|
||||
- Features are strictly optional (graceful degradation)
|
||||
- Core functionality works without the external library
|
||||
- Library is clearly documented as non-essential
|
||||
|
||||
`template.html` for tools with vendor deps loads those deps from CDN for convenient local development. The build script replaces CDN tags with the bundled vendor files in the output.
|
||||
|
||||
### Development vs Production
|
||||
|
||||
| Context | Tailwind / Vendor | How to run |
|
||||
|---------|-------------------|-----------|
|
||||
| Development | CDN (live, from `template.html`) | Open `template.html` directly in Chromium |
|
||||
| Production | Bundled / Static CSS | Run `bash tool/build.sh`, open `dist/tool.html` |
|
||||
|
||||
For mdedit specifically: `template.html` loads Toast UI from CDN and uses Tailwind Play CDN. The build replaces Toast UI with the bundled vendor file and replaces the Tailwind CDN script with the static `css/tailwind-utils.css` subset.
|
||||
|
||||
---
|
||||
|
||||
## JavaScript Architecture
|
||||
|
||||
### Vanilla JS Only
|
||||
|
||||
All tools use plain JavaScript — no TypeScript, no frameworks, no bundlers. Dependencies are managed manually via vendor files.
|
||||
|
||||
### Module Pattern
|
||||
|
||||
Each JS file wraps its code in an IIFE or module-scope block and registers its API on `window.app.modules`:
|
||||
|
||||
```javascript
|
||||
// js/mymodule.js
|
||||
(function() {
|
||||
function doSomething() { ... }
|
||||
|
||||
window.app.modules.mymodule = { doSomething };
|
||||
})();
|
||||
```
|
||||
|
||||
Two top-level globals:
|
||||
|
||||
- `window.app` — per-tool app state, modules, and debug surface (every tool)
|
||||
- `window.zddc` — shared filename/folder/revision parsers from `shared/zddc.js` (every tool)
|
||||
|
||||
No other globals. Never expose implementation internals beyond what's needed for testing.
|
||||
|
||||
### Module Load Order
|
||||
|
||||
JS files are concatenated in the order declared in `build.sh`. Each file can assume earlier files' modules are available on `window.app`. Circular dependencies are not permitted — modules must be layered.
|
||||
|
||||
Typical ordering:
|
||||
|
||||
```
|
||||
app.js ← Declares window.app and top-level state
|
||||
utils.js ← Stateless helpers (no dependencies)
|
||||
store.js ← State management (depends on app.js)
|
||||
[domain].js ← Feature modules (depend on store/utils)
|
||||
main.js ← Initialization (depends on all modules)
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
Tools manage state in one of two patterns:
|
||||
|
||||
**1. Direct state on `window.app`** (archive, classifier, mdedit)
|
||||
|
||||
```javascript
|
||||
window.app = { files: [], selectedFolders: new Set(), modules: {}, ... };
|
||||
```
|
||||
|
||||
State is read directly; mutations trigger explicit re-render calls. Classifier additionally layers a small pub-sub on top via `store.js` (`store.on('files', render)`).
|
||||
|
||||
**2. Proxy-based reactive state** (transmittal)
|
||||
|
||||
```javascript
|
||||
const state = createReactiveState({ mode: 'edit', published: false });
|
||||
state.subscribe((prop, newVal) => { /* auto-update UI */ });
|
||||
state.mode = 'view'; // Proxy notifies all subscribers automatically
|
||||
```
|
||||
|
||||
Use reactive state when the same property drives multiple independent UI elements. Use direct state when the data flow is simple and unidirectional.
|
||||
|
||||
---
|
||||
|
||||
## Tool-Specific Architecture
|
||||
|
||||
### Archive Browser
|
||||
|
||||
**Pattern:** Direct mutation of `window.app.{directories, files, filteredFiles, selectedFiles, ...}`, helper modules namespaced under `window.app.modules.{events, table, urlState, source, ...}`. Supports two source modes (`window.app.sourceMode`): `'local'` (File System Access API) and `'http'` (zddc-server JSON browse).
|
||||
|
||||
**Two-level directory structure required:**
|
||||
|
||||
```
|
||||
root-directory/
|
||||
transmittal-folder/ ← "grouping folder" — must be a subdirectory
|
||||
123456-EL-SPC-0001_A (IFC) - Spec.pdf
|
||||
...
|
||||
```
|
||||
|
||||
Files at the root level are ignored. The grouping folder list and transmittal folder list are populated from the first two levels of the selected directory. Files are only counted in `filteredFiles` after ZDDC filename parsing succeeds.
|
||||
|
||||
**Key DOM IDs:** `#addDirectoryBtn`, `#noDirectoryMessage`, `.main-container`, `#filesTableBody`, `#fileCount`, `#selectedCount`, `#selectAllGroupingCheckbox`.
|
||||
|
||||
---
|
||||
|
||||
### Document Classifier
|
||||
|
||||
**Pattern:** Event-driven store (`store.js`) with `notify()` / `on()` pub-sub, spreadsheet rendering on `'files'` events.
|
||||
|
||||
**File object shape** (as produced by `scanner.js`):
|
||||
|
||||
```javascript
|
||||
{
|
||||
trackingNumber: '123456-EL-SPC-2623',
|
||||
title: 'Specification',
|
||||
revision: 'A',
|
||||
status: 'IFC',
|
||||
extension: 'pdf', // no leading dot
|
||||
originalFilename: '...', // filename without extension
|
||||
name: '...', // full filename with extension
|
||||
path: 'folder/filename.pdf',
|
||||
size: 45000,
|
||||
isDirectory: false,
|
||||
manualFilename: null // set if user overrides computed name
|
||||
}
|
||||
```
|
||||
|
||||
**`computeNewFilename(file)`** (in `utils.js`) returns `file.originalFilename + '.' + file.extension` if any required field is missing.
|
||||
|
||||
**Main app panel** (`#mainApp`) stays hidden (class `hidden`) until a real directory is opened via `showDirectoryPicker`. State can be injected via `store.setFolderTree()` + `store.setSelectedFolders()` for testing without triggering the picker.
|
||||
|
||||
---
|
||||
|
||||
### Markdown Editor (mdedit)
|
||||
|
||||
**Pattern:** Global functions (`window.updateToc`), editor instances managed per file-path in a `Map`, File System Access API for direct file read/write.
|
||||
|
||||
**Dependencies:** Toast UI Editor v3.2.2 (bundled), Tailwind utility subset (static CSS).
|
||||
|
||||
**Toast UI availability check:**
|
||||
|
||||
```javascript
|
||||
if (typeof toastui === 'undefined') {
|
||||
// Graceful degradation — show error message
|
||||
}
|
||||
const editor = new toastui.Editor({ el: container, ... });
|
||||
```
|
||||
|
||||
**Key DOM IDs:** `#app`, `#select-directory`, `#welcome-screen`, `#file-tree`, `#content-container`.
|
||||
|
||||
**File tree:** Populated after `showDirectoryPicker()` resolves. File items are rendered as DOM children of `#file-tree`. Clicking a file opens it in the editor panel.
|
||||
|
||||
---
|
||||
|
||||
### Transmittal Creator
|
||||
|
||||
**Pattern:** Proxy-based reactive state, two-phase hydration, ECDSA digital signatures, SHA-256 file integrity.
|
||||
|
||||
**Two-phase hydration:**
|
||||
|
||||
1. **`populateStatic()`** — called before publishing. Fills all form fields and the file table into the HTML so the output is readable without JavaScript (progressive enhancement for SharePoint, email clients, etc.).
|
||||
2. **`hydrate()`** — called on page load of a published transmittal. Hides the "Not Validated" static warning, runs signature verification, and enables interactive features.
|
||||
|
||||
**Progressive enhancement matrix:**
|
||||
|
||||
| Feature | No JavaScript | With JavaScript |
|
||||
|---------|--------------|-----------------|
|
||||
| Content display | ✅ Full | ✅ Full |
|
||||
| File table | ✅ Shown | ✅ Shown |
|
||||
| Digest / signatures | ✅ Listed | ✅ Listed + cryptographically verified |
|
||||
| Validation status | ⚠️ "Not Validated" badge | ✅ "Verified" / ❌ "Invalid" |
|
||||
| Editing | ❌ Disabled | ✅ Enabled (if draft) |
|
||||
| Column filtering | ❌ No | ✅ Yes |
|
||||
|
||||
**Data store:** A `<script id="transmittal-data" type="application/json">` element embedded in the published HTML holds the full transmittal payload. On load, `data.js` reads and parses it; all UI state derives from this JSON.
|
||||
|
||||
**Reactive state:**
|
||||
|
||||
```javascript
|
||||
// app.state is a Proxy — assignments auto-notify subscribers
|
||||
app.state.mode = 'view'; // Triggers UI updates automatically
|
||||
```
|
||||
|
||||
Subscribe for cross-cutting concerns:
|
||||
```javascript
|
||||
app.state.subscribe((property, newValue) => {
|
||||
if (property === 'mode') updateModeToggleLabel(newValue);
|
||||
});
|
||||
```
|
||||
|
||||
**Security model:** ECDSA P-256 signing of the SHA-256 digest. Signatures are stored in the JSON payload. Any number of signers can co-sign. Verification runs client-side in the browser's Web Crypto API — no server required.
|
||||
|
||||
**Key module globals:** `window.transmittalApp` exposes `app.data`, `app.state`, and `app.modules` for debugging and testing.
|
||||
|
||||
---
|
||||
|
||||
## CSS Architecture
|
||||
|
||||
All tools use vanilla CSS. No frameworks at build time (mdedit's Tailwind utilities are pre-generated static CSS).
|
||||
|
||||
**Common conventions:**
|
||||
|
||||
- CSS variables for theme colors and spacing in `base.css`
|
||||
- Component-scoped class names (no global utilities except where Tailwind provides them)
|
||||
- `.hidden` class uses `display: none !important` for JavaScript show/hide
|
||||
- Print styles in a separate `print.css`
|
||||
|
||||
**mdedit Tailwind subset:**
|
||||
|
||||
`css/tailwind-utils.css` contains only the ~80 Tailwind v3 utility classes actually used in `template.html`. If a new utility class is needed in the template, add it here. Classes follow Tailwind v3 naming and values exactly.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use Playwright with Chromium only (File System Access API requires it).
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
npm test # all tools
|
||||
npx playwright test archive # single tool
|
||||
npx playwright test --debug # debug mode
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
Each tool has a spec file in `tests/`:
|
||||
|
||||
```
|
||||
tests/
|
||||
archive.spec.js ← 2 tests: load + directory scan
|
||||
classifier.spec.js ← 2 tests: load + store injection
|
||||
mdedit.spec.js ← 2 tests: load + file tree render
|
||||
transmittal.spec.js ← 2 tests: paste round-trip + filesystem round-trip
|
||||
fixtures/
|
||||
mock-fs-api.js ← Reusable File System Access API mock
|
||||
transmittal-data.js
|
||||
zddc-filenames.js
|
||||
```
|
||||
|
||||
### Mock File System API
|
||||
|
||||
`MOCK_FS_INIT_SCRIPT` (from `tests/fixtures/mock-fs-api.js`) overrides `showDirectoryPicker`, `showOpenFilePicker`, and `showSaveFilePicker`. Inject it via `page.addInitScript` before navigating.
|
||||
|
||||
```javascript
|
||||
// Flat directory
|
||||
window.__setMockDirectory('name', [{ name: 'file.pdf', content: '...', size: 100 }]);
|
||||
|
||||
// Nested directory tree
|
||||
window.__setMockDirectoryTree('name', {
|
||||
'subfolder': { 'file.pdf': 'content' },
|
||||
'root-file.md': 'content',
|
||||
});
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
Follow the pattern in `tests/transmittal.spec.js`:
|
||||
|
||||
- Use ESM `import` syntax
|
||||
- Inject `MOCK_FS_INIT_SCRIPT` in `test.beforeEach` for any test that navigates to a tool page
|
||||
- Use `waitUntil: 'domcontentloaded'` or `'load'` (not `'networkidle'` — the bundled scripts may keep the network active)
|
||||
- Prefer `page.waitForFunction` over `page.waitForSelector` for app-state readiness
|
||||
- Assert through the store/module API for tests that don't need visible DOM
|
||||
|
||||
---
|
||||
|
||||
## Code Standards
|
||||
|
||||
| Rule | Rationale |
|
||||
|------|-----------|
|
||||
| No `</script>` or any `</tag>` in JS string literals | Breaks inline HTML embedding — escape with `'<' + '/tag>'` or use `<\/` in `sed` at build time |
|
||||
| No external dependencies at runtime | Self-contained output requirement |
|
||||
| No TypeScript, no bundlers | Keeps the build system auditable and simple |
|
||||
| Only `window.app` and `window.zddc` are global | Keeps the global namespace clean; expose only what's needed for debugging |
|
||||
| Defensive input validation | File System API handles and user-pasted data are untrusted |
|
||||
| Update README.md when features ship | Documentation parity is a delivery requirement, not optional |
|
||||
|
||||
---
|
||||
|
||||
## Git Workflow
|
||||
|
||||
**Branching:** short-lived feature branches (`feature/<name>`, `bugfix/<name>`, `hotfix/<name>`), squash-merged to `main` and immediately deleted. Quick fixes (typos, one-liners) go direct to `main`.
|
||||
|
||||
**Commit messages:** Conventional Commits — `<type>(<scope>): <description>`. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`. See `AGENTS.md` for the full table and examples.
|
||||
|
||||
**Releases:** Tag the commit after confirming `dist/` is current. Format: `{project}-v{version}` (e.g. `archive-v1.0.0`). Semantic versioning applies. There is no CI/CD — the built `.html` file is already committed to the repo.
|
||||
|
||||
```bash
|
||||
bash tool/build.sh # rebuild dist/
|
||||
git add -f tool/dist/tool.html # stage if needed
|
||||
git commit -m "chore(tool): rebuild for vX.Y.Z"
|
||||
git tag tool-vX.Y.Z
|
||||
git push origin main --tags
|
||||
|
||||
git tag -l "archive-v*" # list releases
|
||||
git push origin :refs/tags/tag-name # delete a remote tag
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Tool
|
||||
|
||||
1. Create `tool/` with the standard directory layout
|
||||
2. Write `template.html` with `{{CSS_PLACEHOLDER}}` and `{{JS_PLACEHOLDER}}` markers
|
||||
3. Write `tool/build.sh` following the pattern of an existing tool
|
||||
4. Add `bash "$SCRIPT_DIR/tool/build.sh"` to the root `build.sh`
|
||||
5. Add a test project entry to `playwright.config.js`
|
||||
6. Create a stub `tests/tool.spec.js`
|
||||
7. Force-add the dist output: `git add -f tool/dist/tool.html`
|
||||
|
||||
If the tool requires vendor dependencies, download them to `tool/vendor/`, add them to `.gitignore` exclusions if appropriate, and update `build.sh` to inline them (with the `</` escaping step).
|
||||
47
CLAUDE.md
Normal file
47
CLAUDE.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Authoritative docs — read these first
|
||||
|
||||
This repo already has two thorough agent-facing references. **Always consult them before working** — they cover details intentionally omitted here:
|
||||
|
||||
- **`AGENTS.md`** — commands, build-system rules, per-tool parser quirks, testing gotchas, git/worktree workflow, release process, zddc-server notes
|
||||
- **`ARCHITECTURE.md`** — single-file HTML pattern rationale, JS module/state patterns, per-tool architecture, security model
|
||||
|
||||
If something in this CLAUDE.md conflicts with those, those win — and please update them rather than letting drift accumulate.
|
||||
|
||||
## Repo shape
|
||||
|
||||
This is a **monorepo of independent tools**, not one application:
|
||||
|
||||
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/` — five self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Naming: the first four output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`).
|
||||
- `zddc/` — Go HTTP server (separate sub-project, podman/podman-compose; Go 1.24+). Serves `ZDDC_ROOT/index.html` at `GET /` as the landing page; `Accept: application/json` on `/` returns the ACL-filtered project list.
|
||||
- `shared/` — `base.css` plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `theme.js`, `help.js`) included by every tool's build, and `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh`)
|
||||
- `website/` — published artifacts: `index.html` (root URL), `releases/<tool>_v<X.Y.Z>.html` (immutable stable archives), `releases/<tool>_latest.html` (symlink to current stable), `releases/<tool>_{alpha,beta}.html` (mutable channel files), plus bootstrap zips (`install.zip`, `track-{alpha,beta,latest}.zip`). `--release` is the only path to publishing — there is no `website/dev/`.
|
||||
- `tests/` — Playwright specs (Chromium only, requires File System Access API). `tests/schema.spec.js` validates `transmittal.schema.json` against canonical fixtures via `ajv` (only dev dep besides Playwright)
|
||||
|
||||
## Most-used commands
|
||||
|
||||
```bash
|
||||
sh build.sh # build all five HTML tools (dist/ only)
|
||||
sh tool/build.sh # build one (archive|transmittal|classifier|mdedit|landing)
|
||||
sh tool/build.sh --release [version] # cut stable; tag, write website/releases/<tool>_v<ver>.html, refresh _latest symlink
|
||||
sh tool/build.sh --release alpha|beta # cut channel build; overwrites website/releases/<tool>_<channel>.html (mutable, no tag)
|
||||
npm test # all Playwright specs (build first!)
|
||||
npx playwright test <tool> # one spec
|
||||
./dev-server start # ./dev-server stop # cache-busting HTTP on :8000
|
||||
```
|
||||
|
||||
No lint/typecheck/format commands exist — vanilla JS + POSIX sh by design.
|
||||
|
||||
## Things that bite if you forget
|
||||
|
||||
- **`dist/` is gitignored but force-committed** (`git add -f tool/dist/tool.html`). Never hand-edit a `dist/` file.
|
||||
- **Never write to `website/index.html` or `website/releases/*` directly** — promote via `sh tool/build.sh --release [version|alpha|beta]`. Stable releases write `website/releases/<tool>_v<ver>.html` (immutable) and refresh `<tool>_latest.html`; alpha/beta overwrite `<tool>_<channel>.html` in place.
|
||||
- **Always build before running tests** — Playwright opens `dist/tool.html` via `file://`.
|
||||
- **`</` in JS string/template literals breaks inline `<script>`** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining.
|
||||
- **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests.
|
||||
- **Two globals only**: `window.app` (per-tool app state + modules) and `window.zddc` (shared library). No others — anything that crosses tool boundaries goes through one of these.
|
||||
- **Worktrees live at `~/src/zddc-<branch>`.** Check `git worktree list` before starting a feature branch; never `git checkout`/`switch` inside a worktree another agent might be using.
|
||||
- **Build scripts are POSIX `sh` with `set -eu`**, not bash. `concat_files` takes positional args only.
|
||||
661
LICENSE.txt
Normal file
661
LICENSE.txt
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
55
README.md
Normal file
55
README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Zero Day Document Control (ZDDC)
|
||||
|
||||
**The Universal Distributed Filing Cabinet**
|
||||
|
||||
ZDDC is an information management convention plus a small set of single-file HTML tools. Every deliverable's filename encodes its tracking number, revision, status, and title; every transmittal folder is date-prefixed and self-describing. A plain shared folder becomes a fully searchable, auditable archive — no server, no database, no software required to read it.
|
||||
|
||||
The name "Zero Day Document Control" comes from the convention itself — adopt it on day zero of a project, with no setup time. The tools are *optional* interfaces around the structure; the structure works without them.
|
||||
|
||||
> **For end users**: <https://zddc.varasys.io/> introduces the project, links to all tool channels (stable / beta / alpha), and offers `install.zip` for self-hosted deployments.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| **[Archive Browser](https://zddc.varasys.io/releases/archive_latest.html)** | Browse, search, and filter a project archive folder. Group by transmittal, export selections as ZIP. |
|
||||
| **[Transmittal Creator](https://zddc.varasys.io/releases/transmittal_latest.html)** | Self-contained HTML transmittal records with SHA-256 checksums and optional digital signatures. |
|
||||
| **[Document Classifier](https://zddc.varasys.io/releases/classifier_latest.html)** | Spreadsheet-like bulk-renamer that copy/pastes with Excel and writes back to disk. |
|
||||
| **[Markdown Editor](https://zddc.varasys.io/releases/mdedit_latest.html)** | Browser-based markdown editor with YAML front matter, TOC, and direct local file access. |
|
||||
|
||||
Each tool is published in three channels (stable, beta, alpha) at `https://zddc.varasys.io/releases/<tool>_<channel>.html`. Append `?v=alpha` (or `?v=0.0.4`, etc.) to any URL to switch versions for one request. See [`bootstrap/README.md`](bootstrap/README.md) for the install / pin / audit story.
|
||||
|
||||
## File-naming convention
|
||||
|
||||
The full specification — filename format, tracking numbers, revision rules, status codes, folder naming, and the transmittal workflow — lives at <https://zddc.varasys.io/reference.html>.
|
||||
|
||||
Quick example: `123456-EL-SPC-2623_A (IFR) - Specification For Switchgear.pdf`
|
||||
|
||||
## Build & develop
|
||||
|
||||
```bash
|
||||
git clone https://codeberg.org/VARASYS/ZDDC.git && cd ZDDC
|
||||
|
||||
sh build.sh # build all tools (writes to dist/ only)
|
||||
sh archive/build.sh # build one tool
|
||||
|
||||
sh archive/build.sh --release # cut stable; auto-bumps patch from last tag
|
||||
sh archive/build.sh --release 0.1.0 # explicit version
|
||||
sh archive/build.sh --release alpha # cut alpha (mutable channel, no tag)
|
||||
sh archive/build.sh --release beta # cut beta
|
||||
|
||||
npm install && npx playwright install chromium && npm test # tests
|
||||
./dev-server start # cache-busting HTTP on :8000
|
||||
```
|
||||
|
||||
Authoritative build/release docs are in [`AGENTS.md`](AGENTS.md). Architecture notes (single-file rationale, JS module pattern, security model) are in [`ARCHITECTURE.md`](ARCHITECTURE.md). zddc-server (optional Go HTTP server with ACL and a virtual archive index) is in [`zddc/README.md`](zddc/README.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
ZDDC is an open source project hosted on Codeberg at <https://codeberg.org/VARASYS/ZDDC>. Bug reports, feature requests, and pull requests welcome.
|
||||
|
||||
ZDDC is designed for zero configuration to start and minimal configuration overall — feature proposals are filtered through that lens.
|
||||
|
||||
## License
|
||||
|
||||
[GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.en.html). Free to use, modify, and distribute, including commercially, under the terms of the license. Provided "as is" without warranty.
|
||||
277
archive/README.md
Normal file
277
archive/README.md
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
# Archive Browser
|
||||
|
||||
[← Back to ZDDC](../README.md)
|
||||
|
||||
Your digital filing cabinet - a complete document management system in a single HTML file. No installation, no updates, no cloud required. Just open it and start organizing.
|
||||
|
||||
**[🔗 Open Archive Browser](dist/archive.html)** - Click to use online, or right-click → "Save Link As" to keep your own copy forever.
|
||||
|
||||
## What Makes This Special?
|
||||
|
||||
This is a "record player with the record" - the entire application travels with the file. Save it to a USB drive, email it to a colleague, or archive it with your project files. It will work exactly the same way in 20 years as it does today. No dependencies, no obsolescence.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Click "Select Directory"** - Choose your project folder
|
||||
2. **All folders auto-expand** - See everything at once
|
||||
3. **Type to filter** - Use the search boxes to find files instantly
|
||||
4. **Click to sort** - Any column header sorts your data
|
||||
5. **Download selected** - Check boxes and download as ZIP
|
||||
|
||||
## Overview
|
||||
|
||||
The archive browser presents a two-pane interface:
|
||||
- **Navigation Pane** (left): Your folder hierarchy with smart filtering
|
||||
- **Content Area** (right): All your files in a searchable, sortable table
|
||||
|
||||
## User Interface Layout
|
||||
|
||||
### Navigation Pane
|
||||
The navigation pane displays a hierarchical folder structure with:
|
||||
|
||||
1. **Grouping Folders** (top level)
|
||||
- Folders that don't match transmittal naming convention
|
||||
- Used for organizational hierarchy (e.g., permissions, departments)
|
||||
- Has its own autofilter input
|
||||
- Supports multi-select (Shift+Click, Ctrl+Click)
|
||||
- **Right-click context menu** for recursive select/deselect of folder trees
|
||||
- **Collapsible section** with toggle button to hide/show when not needed
|
||||
- **Resizable height** - drag the divider to adjust space allocation
|
||||
- Default: 250px height, can be collapsed to header-only
|
||||
- Folders named "incoming" (case-insensitive) are excluded from default selection
|
||||
|
||||
2. **Transmittal Folders** (displayed in a flat sorted list in a separate section below the grouping folders)
|
||||
- Follow naming convention: `YYYY-MM-DD_TRACKINGNUMBER (STATUS) - TITLE`
|
||||
- Example: `2025-09-15_A101-203 (IFC) - Site Plan`
|
||||
- Has its own autofilter input
|
||||
- Supports multi-select
|
||||
- Only folders within selected grouping folders are visible
|
||||
- **Grouped by date** with collapsible date headers showing folder count
|
||||
- **Expand/collapse all** toggle button in section header
|
||||
- Date removed from individual folder display (shown in group header instead)
|
||||
- All transmittal folders selected by default (except those under "incoming")
|
||||
|
||||
### Content Area
|
||||
Displays files from all selected transmittal folders in a unified table.
|
||||
|
||||
**Table Columns**:
|
||||
1. **Tracking Number** - Extracted from filename
|
||||
2. **Title** - Extracted from filename
|
||||
3. **Revisions** - Shows all available revisions/documents for a tracking number
|
||||
- Each revision shows: revision identifier, status, and file links
|
||||
- Multiple files per revision supported (e.g., PDF, DWG)
|
||||
- Checkboxes for selecting individual files
|
||||
|
||||
The Revisions column must provide an efficient way to both display all revisions and modifiers compactly while allowing for efficient selection of specific revisions.
|
||||
|
||||
|
||||
## Core Features
|
||||
|
||||
### 🔍 Smart Search & Filter
|
||||
- **Find anything instantly** - Type in any filter box to narrow results
|
||||
- **Power search** - Use `+must have` or `-exclude` for precise filtering
|
||||
- **Excel-like sorting** - Click any column header to sort your data
|
||||
|
||||
### 📁 Organize Your Files
|
||||
- **Drag & drop** - Drop files onto folders to create organized transmittals
|
||||
- **Smart naming** - Automatically extracts document info from ZDDC filenames
|
||||
- **Version tracking** - See all revisions of a document in one place
|
||||
- **Batch operations** - Select multiple files for download or export
|
||||
|
||||
### 🔒 Data Integrity
|
||||
- **SHA-256 checksums** - Verify files haven't changed
|
||||
- **Hash caching** - Fast rescanning of large archives
|
||||
- **Export to ZIP/CSV** - Take your data with you
|
||||
|
||||
Files are sorted by tracking number first, then by revision in proper order (~A, A, B, C+C1, C, 1, 2, 3, etc.)
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Frontend Stack
|
||||
- **Vanilla JavaScript**: No framework dependencies
|
||||
- **Inline CSS**: Self-contained styling
|
||||
- **File System Access API**: Local directory access
|
||||
- **Web Crypto API**: SHA-256 file hashing
|
||||
|
||||
### Build System
|
||||
- Modular architecture with separate CSS and JavaScript files
|
||||
- Build script concatenates and inlines all assets
|
||||
- Produces single self-contained HTML file
|
||||
- No external dependencies in final output
|
||||
|
||||
Uses the same build.sh structure as the transmittal project (requires Git Bash on Windows).
|
||||
|
||||
|
||||
## Additional Features
|
||||
|
||||
### File Operations
|
||||
- **Download Selected**: Creates ZIP file of checked files
|
||||
- **Export CSV**: Exports only visible/filtered files with metadata
|
||||
- **Drag & Drop**: Drop files onto table rows to copy metadata
|
||||
- **File Renaming**: Modal for fixing non-conforming filenames
|
||||
|
||||
### Context Menu Operations (Right-click on Grouping Folders)
|
||||
- **Select This & Subfolders**: Recursively select folder and all descendants
|
||||
- **Deselect This & Subfolders**: Recursively deselect folder and all descendants
|
||||
- **Select All Visible**: Select all currently visible grouping folders
|
||||
- **Deselect All**: Clear all grouping folder selections
|
||||
|
||||
Users can drag and drop files onto a grouping folder. The system creates a transmittal folder with the correct naming convention and displays a dialog where users can:
|
||||
- Confirm/edit the transmittal folder name
|
||||
- Review and correct file names to ensure ZDDC compliance
|
||||
- See preview of final file organization before committing
|
||||
|
||||
### Data Management
|
||||
- **SHA-256 Hashing**: Integrity verification for all files
|
||||
- **Hash Cache**: `.hashes.json` file to avoid re-hashing unchanged files
|
||||
- **Refresh**: Re-scan directories for changes
|
||||
|
||||
Implements hash caching by creating a `.hashes.json` file in each scanned directory (when writable) to store file hashes and modification times, significantly improving performance on subsequent scans.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Resizable Interface
|
||||
- **Column Resizing**: Draggable column borders with persistent widths
|
||||
- **Navigation Pane Width**: Drag horizontal divider between nav pane and content area
|
||||
- **Section Heights**: Drag vertical divider between grouping and transmittal sections
|
||||
- Visual feedback (blue highlight) when hovering or actively resizing
|
||||
- Minimum/maximum sizes enforced to prevent unusable layouts
|
||||
|
||||
### Performance Optimizations
|
||||
- Standard scrolling with sticky headers (no virtual scrolling needed)
|
||||
- Debounced search inputs
|
||||
- Progressive file scanning
|
||||
- Cached file metadata
|
||||
|
||||
The table uses standard scrolling with sticky headers for navigation. No pagination or virtual scrolling is needed.
|
||||
|
||||
### Folder Hierarchy Logic
|
||||
1. **Grouping Folders**: Any folder not matching transmittal convention
|
||||
2. **Transmittal Folders**: Match `YYYY-MM-DD_TRACKINGNUMBER (STATUS) - TITLE`
|
||||
3. **Selection Cascade** (strict enforcement):
|
||||
- If grouping folders exist, at least one must be selected to see transmittal folders
|
||||
- Selecting grouping folder shows only its transmittal folders
|
||||
- At least one transmittal folder must be selected to see files
|
||||
- Selecting transmittal folders shows only their files
|
||||
- Multiple selections combine results
|
||||
- **Default Selection**: All folders selected except "incoming" and its subfolders
|
||||
|
||||
### File Grouping Logic
|
||||
Files with the same tracking number are grouped together, showing:
|
||||
- Base revisions (A, B, C, 1, 2, 3)
|
||||
- Revision modifiers (+C1, +B1, +N1)
|
||||
- Draft indicators (~A, ~B)
|
||||
- Multiple file types per revision
|
||||
|
||||
|
||||
## Filtering
|
||||
|
||||
Each column has a text filter input. The syntax supports simple boolean logic per field:
|
||||
- Required token: `+token` must be present in that field.
|
||||
- Prohibited token: `-token` must not be present.
|
||||
- Parentheses group sub‑expressions: `(+revA plan)`.
|
||||
- Terms (without +/−) are OR’ed within the same field: `as-built asbuilt`.
|
||||
- Wildcard support: `+token*` (starts with), `*token` (ends with), `token` (contains)
|
||||
Examples:
|
||||
|
||||
- Only PDFs: in EXT filter, type `+pdf`.
|
||||
- Exclude superseded: in Status, type `-superseded`.
|
||||
- Revisions A or B, but not Draft: in Revision, type `revA revB -draft`.
|
||||
- Title contains both "floor" and "plan": Title `+floor +plan`.
|
||||
|
||||
## UI/UX Considerations
|
||||
|
||||
### Visual Design
|
||||
- Clean, professional interface
|
||||
- Hover states for interactive elements
|
||||
- Clear visual hierarchy
|
||||
- Consistent spacing and alignment
|
||||
- Status color coding (optional)
|
||||
|
||||
Status codes are prominently displayed alongside revisions without color coding. The status is always shown as part of the revision information for clarity.
|
||||
|
||||
### Accessibility
|
||||
- Keyboard navigation support (Ctrl+Click, Shift+Click, Ctrl+A)
|
||||
- Right-click context menus for advanced operations
|
||||
- ARIA labels for screen readers
|
||||
- High contrast mode support
|
||||
- Resizable text and interface elements
|
||||
- Collapsible sections to reduce visual clutter
|
||||
|
||||
### Error Handling
|
||||
- Graceful handling of permission errors
|
||||
- Clear error messages
|
||||
- Recovery options
|
||||
- Console logging for debugging
|
||||
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
- **Required**: Chromium-based browsers (Chrome, Edge, Brave) for File System Access API
|
||||
- **Fallback**: Display message for unsupported browsers
|
||||
- **Print Styles**: Optimized for US Letter (8.5×11")
|
||||
- **Responsive Design**: Works on desktop and tablet screens
|
||||
|
||||
## Windows Path Length Limitations
|
||||
|
||||
The application includes safeguards for Windows' 260-character path limit:
|
||||
|
||||
- **Path Length Monitoring**: Warns when paths exceed 240 characters
|
||||
- **Depth Limits**: Stops scanning directories deeper than 10 levels
|
||||
- **Path Truncation**: Long paths are truncated in the UI while maintaining full paths internally
|
||||
- **Graceful Failure**: Files with paths too long are skipped with console warnings
|
||||
- **Display Optimization**: Shows `...` with shortened paths for better readability
|
||||
|
||||
To enable long path support in Windows 10 (1607+):
|
||||
1. Run `gpedit.msc` as Administrator
|
||||
2. Navigate to: Computer Configuration → Administrative Templates → System → Filesystem
|
||||
3. Enable "Enable Win32 long paths"
|
||||
4. Restart your computer
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- No data leaves the browser
|
||||
- All processing happens locally
|
||||
- Directory permissions requested per-session
|
||||
- No tracking or analytics
|
||||
|
||||
## File Preview
|
||||
|
||||
Clicking on a file link opens it in a new browser tab if the browser can display it (PDFs, images, text files), otherwise triggers a download.
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. **Single HTML file** (`archive.html`) containing all functionality
|
||||
2. **Modular source code** organized as:
|
||||
- `js/` - JavaScript modules
|
||||
- `css/` - CSS modules
|
||||
- `template.html` - HTML template
|
||||
- `build.sh` - Build script
|
||||
3. **Documentation** embedded in the final HTML file
|
||||
|
||||
## CSS/JS Architecture
|
||||
|
||||
### CSS Files (loaded in dependency order)
|
||||
|
||||
| File | Size | Purpose |
|
||||
|------|------|---------|
|
||||
| `css/base.css` | 1.5KB | Core styles, layout reset, typography, theme variables |
|
||||
| `css/components.css` | 10KB | Button styles, inputs, modal dialogs, menu systems |
|
||||
| `css/layout.css` | 3.7KB | Page structure, header/footer, container width |
|
||||
| `css/table.css` | 3.4KB | Table styling, cell padding, border styles, sorting headers |
|
||||
| `css/print.css` | 2.2KB | Print-specific styles, hide interactive elements |
|
||||
|
||||
### JavaScript Modules (loaded in dependency order)
|
||||
|
||||
| File | Size | Purpose |
|
||||
|------|------|---------|
|
||||
| `js/parser.js` | 7.4KB | ZDDC filename parsing, revision extraction, status validation |
|
||||
| `js/hash.js` | 5.9KB | SHA-256 hashing for file integrity verification |
|
||||
| `js/drag-drop.js` | 9.1KB | File system access API integration, drag-and-drop handling |
|
||||
| `js/directory.js` | 11.9KB | Directory scanning, folder tree rendering, path handling |
|
||||
| `js/filtering.js` | 8.8KB | Boolean filter logic, column filtering, show/hide rows |
|
||||
| `js/table.js` | 26KB | Table rendering, row management, selection handling |
|
||||
| `js/export.js` | 8.2KB | JSON export, file download with ZDDC naming |
|
||||
| `js/events.js` | 18.9KB | Event bus, state change notifications, UI coordination |
|
||||
| `js/app.js` | 19.6KB | Main entry point, initialization, state management |
|
||||
|
||||
88
archive/build.sh
Normal file
88
archive/build.sh
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
root_dir=$(cd "$(dirname "$0")" && pwd)
|
||||
. "$root_dir/../shared/build-lib.sh"
|
||||
|
||||
src_html="$root_dir/template.html"
|
||||
output_dir="$root_dir/dist"
|
||||
output_html="$output_dir/archive.html"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
ensure_exists "$src_html"
|
||||
|
||||
css_temp=$(mktemp)
|
||||
js_raw=$(mktemp)
|
||||
js_temp=$(mktemp)
|
||||
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# CSS files to concatenate in order
|
||||
concat_files \
|
||||
"../shared/base.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
"css/components.css" \
|
||||
"css/table.css" \
|
||||
"css/print.css" \
|
||||
> "$css_temp"
|
||||
|
||||
# JavaScript files to concatenate in order
|
||||
concat_files \
|
||||
"../shared/zddc.js" \
|
||||
"../shared/hash.js" \
|
||||
"../shared/theme.js" \
|
||||
"js/init.js" \
|
||||
"js/parser.js" \
|
||||
"js/source.js" \
|
||||
"js/hash.js" \
|
||||
"js/drag-drop.js" \
|
||||
"js/directory.js" \
|
||||
"../shared/zddc-filter.js" \
|
||||
"js/filtering.js" \
|
||||
"js/table.js" \
|
||||
"js/export.js" \
|
||||
"js/presets.js" \
|
||||
"js/url-state.js" \
|
||||
"js/events.js" \
|
||||
"js/app.js" \
|
||||
"../shared/help.js" \
|
||||
> "$js_raw"
|
||||
|
||||
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
||||
# for a closing </script> tag. Required for any tool with template literals.
|
||||
escape_js_close_tags "$js_raw" "$js_temp"
|
||||
|
||||
compute_build_label "archive" "${1:-}" "${2:-}"
|
||||
|
||||
# Process template: inject CSS/JS, substitute build label, strip CDN refs.
|
||||
awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" '
|
||||
/\{\{CSS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < css_file) > 0) print line
|
||||
close(css_file)
|
||||
next
|
||||
}
|
||||
/\{\{JS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < js_file) > 0) print line
|
||||
close(js_file)
|
||||
next
|
||||
}
|
||||
/\{\{BUILD_LABEL\}\}/ {
|
||||
if (is_red == "1") {
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
|
||||
} else {
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
|
||||
}
|
||||
print
|
||||
next
|
||||
}
|
||||
/<script src="https?:\/\// { next }
|
||||
/<link rel="stylesheet" href="https?:\/\// { next }
|
||||
{ print }
|
||||
' "$src_html" > "$output_html"
|
||||
|
||||
echo "Wrote $output_html"
|
||||
|
||||
if [ "$is_release" = "1" ]; then
|
||||
promote_release "archive"
|
||||
fi
|
||||
32
archive/css/base.css
Normal file
32
archive/css/base.css
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/* Archive-specific base overrides
|
||||
Reset, tokens, and font are provided by shared/base.css */
|
||||
|
||||
#appContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Scan spinner */
|
||||
.scan-spinner {
|
||||
display: inline-block;
|
||||
width: 0.85em;
|
||||
height: 0.85em;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
vertical-align: middle;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
899
archive/css/components.css
Normal file
899
archive/css/components.css
Normal file
|
|
@ -0,0 +1,899 @@
|
|||
/* Archive component styles — tokens from shared/base.css */
|
||||
|
||||
/* Select All checkbox label */
|
||||
.select-all-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.select-all-label input[type="checkbox"] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* One-line bar variant — sits below the section header */
|
||||
.select-all-bar {
|
||||
padding: 0.2rem 0;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
/* Filter + Select All inline row */
|
||||
.filter-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.filter-select-row .filter-input {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Inline variant: label to the right of the filter, text above checkbox */
|
||||
.select-all-inline {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.1;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.select-all-inline input[type="checkbox"] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Form Inputs */
|
||||
.filter-input,
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.filter-input:focus,
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.filter-input.filter-active {
|
||||
background: rgba(234, 179, 8, 0.18);
|
||||
border-color: rgba(234, 179, 8, 0.7);
|
||||
}
|
||||
|
||||
/* Form Groups */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Checkboxes */
|
||||
input[type="checkbox"] {
|
||||
margin-right: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Folder Tree Chevrons */
|
||||
.folder-chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.folder-chevron:not(.collapsed) {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.folder-chevron:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.folder-chevron-placeholder {
|
||||
width: 1rem;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Folder Items */
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 3px;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.folder-item.selected {
|
||||
background: var(--bg-selected);
|
||||
color: inherit;
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: calc(0.5rem - 3px);
|
||||
}
|
||||
|
||||
.folder-item.selected:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.folder-item-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Transmittal folder formatting */
|
||||
.transmittal-folder-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
[data-folder-type="transmittal"] {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.transmittal-first-line {
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.transmittal-second-line {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Empty filter message in folder lists */
|
||||
.folder-list-empty {
|
||||
padding: 0.75rem 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Focus styles for keyboard navigation */
|
||||
.folder-list:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.folder-list:focus .folder-item:focus {
|
||||
outline: 1px dotted var(--primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* ── Folder type toggle bar ─────────────────────────────────────────────── */
|
||||
.folder-type-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-type-toggle {
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-type-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.folder-type-toggle.active {
|
||||
background: var(--primary);
|
||||
color: var(--text-light);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.folder-type-toggle.active:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Date Group Headers */
|
||||
.date-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.date-group-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.date-group-toggle {
|
||||
font-size: 0.8em;
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date-group-date {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.date-group-count {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Nav section header with button */
|
||||
.nav-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
margin: -1rem -1rem 0.75rem -1rem;
|
||||
padding: 0.4rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-section-header h3 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Modals */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-large {
|
||||
width: 80vw;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Preview Table */
|
||||
.preview-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-table th,
|
||||
.preview-table td {
|
||||
text-align: left;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview-table th {
|
||||
font-weight: 600;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Drag & Drop */
|
||||
.drag-over {
|
||||
background: var(--bg-selected) !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid var(--border);
|
||||
border-top: 2px solid var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Revision Title Styling */
|
||||
.titles-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.revision-title-base,
|
||||
.revision-title-modifier {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.revision-title-base:last-child,
|
||||
.revision-title-modifier:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.revision-title-base {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.revision-title-modifier {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Modifier Filter Dropdown */
|
||||
.modifier-filter-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.modifier-filter-btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.modifier-filter-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
min-width: 180px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-dark);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modifier-filter-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.modifier-filter-header label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modifier-filter-list {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.modifier-filter-item {
|
||||
padding: 0.4rem 0.75rem;
|
||||
}
|
||||
|
||||
.modifier-filter-item label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.modifier-filter-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.modifier-base {
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modifier-type {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Active toggle button state */
|
||||
.btn-active {
|
||||
background: var(--primary);
|
||||
color: var(--text-light);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-active:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Path Error Row Warning */
|
||||
.file-row-path-error {
|
||||
background: rgba(217, 119, 6, 0.08) !important;
|
||||
}
|
||||
|
||||
.file-row-path-error:hover {
|
||||
background: rgba(217, 119, 6, 0.15) !important;
|
||||
}
|
||||
|
||||
.path-error-indicator {
|
||||
color: var(--warning);
|
||||
cursor: help;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.file-link-disabled {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-link-disabled:hover {
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* PDF Preview Toggle */
|
||||
.preview-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.preview-toggle-label:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.preview-toggle-label input[type="checkbox"] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preview-toggle-label input[type="checkbox"]:checked + span {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Download progress indicator ────────────────────────────────────────── */
|
||||
.progress-indicator {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.progress-indicator__message {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.progress-indicator__track {
|
||||
background: var(--bg-secondary);
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-indicator__fill {
|
||||
background: var(--primary);
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.progress-indicator__label {
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Welcome screen list ─────────────────────────────────────────────────── */
|
||||
.welcome-list {
|
||||
text-align: left;
|
||||
margin: 0.5rem auto;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* ── Windows path tip (inside welcome screen) ────────────────────────────── */
|
||||
.windows-tip {
|
||||
text-align: left;
|
||||
margin: 1rem auto;
|
||||
max-width: 500px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.windows-tip summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.windows-tip__body {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.windows-tip__body > p:first-child {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.windows-tip__body ol {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.windows-tip__code {
|
||||
display: block;
|
||||
margin: 0.25rem 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.windows-tip__note {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Outstanding virtual transmittal — pinned at top of transmittal list */
|
||||
.outstanding-transmittal {
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.outstanding-label {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.outstanding-transmittal.selected .outstanding-label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Reset Filters Button */
|
||||
.btn-icon-only {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 1.1rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon-only:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-icon-only:active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Toolbar separator */
|
||||
.toolbar-separator {
|
||||
width: 1px;
|
||||
height: 1.5rem;
|
||||
background: var(--border);
|
||||
margin: 0 0.25rem;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Preset dropdown ─────────────────────────────────────────────────────── */
|
||||
.preset-section {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preset-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
min-width: 350px;
|
||||
max-height: 400px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-dark);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preset-section-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
padding: 0.5rem 0.75rem 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.preset-list {
|
||||
padding: 0.25rem 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preset-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.preset-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.preset-item .preset-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.preset-item .preset-delete:hover {
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.preset-no-presets {
|
||||
padding: 0.75rem 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preset-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.preset-projects-list {
|
||||
padding: 0.25rem 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preset-project-item {
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.preset-project-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.preset-project-label input[type="checkbox"] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preset-footer-actions {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preset-footer-naming {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preset-name-input {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.preset-name-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.preset-section-top,
|
||||
.preset-section-bottom {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.preset-section-bottom {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
279
archive/css/layout.css
Normal file
279
archive/css/layout.css
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/* Archive layout — tokens from shared/base.css */
|
||||
|
||||
/* Header — shared/base.css provides base .app-header; add archive-specific overrides */
|
||||
.app-header {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Main Container */
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Navigation Pane */
|
||||
.nav-pane {
|
||||
width: 300px;
|
||||
min-width: 200px;
|
||||
background: var(--bg);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Grouping section - larger default size */
|
||||
.nav-section:first-child {
|
||||
flex: 0 0 auto;
|
||||
height: 250px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
/* Grouping section when collapsed */
|
||||
.nav-section:first-child.collapsed {
|
||||
height: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Transmittal section takes remaining space */
|
||||
.nav-section:last-child {
|
||||
flex: 1;
|
||||
min-height: 150px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Nav section content wrapper */
|
||||
.nav-section-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Hide content when collapsed */
|
||||
.nav-section.collapsed .nav-section-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Resize handles — persistent 1px divider; grab cursor on hover */
|
||||
.resize-handle-horizontal {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 5px;
|
||||
cursor: ew-resize;
|
||||
z-index: 10;
|
||||
/* Persistent 1px right-edge indicator */
|
||||
border-right: 1px solid var(--border-dark);
|
||||
}
|
||||
|
||||
.resize-handle-horizontal:hover,
|
||||
.resize-handle-horizontal.resizing {
|
||||
background: rgba(42, 90, 138, 0.25);
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.resize-handle-vertical {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -3px;
|
||||
height: 6px;
|
||||
cursor: ns-resize;
|
||||
z-index: 10;
|
||||
/* Persistent 1px bottom-edge indicator */
|
||||
border-bottom: 1px solid var(--border-dark);
|
||||
}
|
||||
|
||||
.resize-handle-vertical:hover,
|
||||
.resize-handle-vertical.resizing {
|
||||
background: rgba(42, 90, 138, 0.25);
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.nav-section h3 {
|
||||
font-size: 1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-section h3 {
|
||||
font-size: 1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-top: 0.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Content Area */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.content-header .content-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.content-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Table Container */
|
||||
.table-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
margin: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Status Bar */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 0.35rem 1rem;
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Project warning banner */
|
||||
.project-warning-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: #fff3cd;
|
||||
border-bottom: 1px solid #ffc107;
|
||||
color: #664d03;
|
||||
font-size: 0.875rem;
|
||||
gap: 12px;
|
||||
}
|
||||
.project-warning-banner.hidden { display: none; }
|
||||
.project-warning-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #664d03;
|
||||
font-size: 1rem;
|
||||
padding: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-state-content h2 {
|
||||
color: var(--text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state-content p {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Project access warning banner */
|
||||
.project-warning-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
background: #fff3cd;
|
||||
border-bottom: 1px solid #ffc107;
|
||||
color: #664d03;
|
||||
font-size: 0.875rem;
|
||||
gap: 12px;
|
||||
}
|
||||
.project-warning-banner.hidden { display: none; }
|
||||
.project-warning-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #664d03;
|
||||
font-size: 1rem;
|
||||
padding: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
121
archive/css/print.css
Normal file
121
archive/css/print.css
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/* Print styles */
|
||||
|
||||
@media print {
|
||||
/* Hide UI elements */
|
||||
.app-header,
|
||||
.nav-pane,
|
||||
.content-header,
|
||||
.status-bar,
|
||||
.modal,
|
||||
.btn,
|
||||
.filter-input,
|
||||
.global-search,
|
||||
.column-filter,
|
||||
input[type="checkbox"],
|
||||
.resize-handle,
|
||||
.sort-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Reset layout */
|
||||
body {
|
||||
font-size: 10pt;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#appContainer {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin: 0;
|
||||
border: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Table adjustments */
|
||||
.files-table {
|
||||
font-size: 9pt;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.files-table thead {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.files-table th {
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #000;
|
||||
padding: 4pt 6pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.files-table td {
|
||||
border: 1px solid #000;
|
||||
padding: 3pt 6pt;
|
||||
}
|
||||
|
||||
.files-table tbody tr:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Show only text content for revisions */
|
||||
.revision-item {
|
||||
display: inline;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.file-link {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.file-link::after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 8pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Page breaks */
|
||||
.files-table {
|
||||
page-break-inside: auto;
|
||||
}
|
||||
|
||||
.files-table tr {
|
||||
page-break-inside: avoid;
|
||||
page-break-after: auto;
|
||||
}
|
||||
|
||||
/* Header on each page */
|
||||
@page {
|
||||
size: letter portrait;
|
||||
margin: 0.5in;
|
||||
}
|
||||
|
||||
/* Add document title */
|
||||
body::before {
|
||||
content: "Archive Browser Report";
|
||||
display: block;
|
||||
font-size: 16pt;
|
||||
font-weight: bold;
|
||||
margin-bottom: 12pt;
|
||||
}
|
||||
|
||||
/* Add timestamp */
|
||||
body::after {
|
||||
content: "Generated: " attr(data-print-date);
|
||||
display: block;
|
||||
margin-top: 12pt;
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
231
archive/css/table.css
Normal file
231
archive/css/table.css
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/* Table styles */
|
||||
|
||||
.files-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Table Header */
|
||||
.files-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.files-table th {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 2px solid var(--border);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.th-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sortable .th-content {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sortable .th-content:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Sort Indicators */
|
||||
.sort-indicator {
|
||||
display: inline-block;
|
||||
width: 0.75rem;
|
||||
height: 1rem;
|
||||
margin-left: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sort-indicator::before,
|
||||
.sort-indicator::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.sort-indicator::before {
|
||||
top: 0;
|
||||
border-width: 0 0.375rem 0.375rem 0.375rem;
|
||||
border-color: transparent transparent var(--border-dark) transparent;
|
||||
}
|
||||
|
||||
.sort-indicator::after {
|
||||
bottom: 0;
|
||||
border-width: 0.375rem 0.375rem 0 0.375rem;
|
||||
border-color: var(--border-dark) transparent transparent transparent;
|
||||
}
|
||||
|
||||
th[data-sort="asc"] .sort-indicator::before {
|
||||
border-bottom-color: var(--text);
|
||||
}
|
||||
|
||||
th[data-sort="desc"] .sort-indicator::after {
|
||||
border-top-color: var(--text);
|
||||
}
|
||||
|
||||
/* Resize Handle */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* Table Body */
|
||||
.files-table tbody tr {
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.files-table tbody tr.group-last {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.files-table tbody tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.files-table td {
|
||||
padding: 0.25rem 1rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* Tracking Number Column */
|
||||
td[data-field="trackingNumber"],
|
||||
th[data-sort="trackingNumber"] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td[data-field="trackingNumber"] {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Revisions Column */
|
||||
.revision-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.revision-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.revision-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.revision-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.revision-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 0.5rem;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.revision-id {
|
||||
font-weight: 600;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.revision-status {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.revision-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.file-link,
|
||||
.file-link-disabled {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-right: 0.25rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.file-link {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.file-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
.file-ext {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Empty Table State */
|
||||
.empty-table {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Column Widths */
|
||||
.files-table th:nth-child(1),
|
||||
.files-table td:nth-child(1) {
|
||||
width: 240px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.files-table th:nth-child(2),
|
||||
.files-table td:nth-child(2) {
|
||||
width: 40%;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.files-table th:nth-child(3),
|
||||
.files-table td:nth-child(3) {
|
||||
width: auto;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
/* File size — half the height of the extension badge, left-aligned below it */
|
||||
.file-size {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.5em;
|
||||
line-height: 1;
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
|
||||
/* Active column filter highlight */
|
||||
.column-filter.filter-active {
|
||||
background: rgba(234, 179, 8, 0.18);
|
||||
border-color: rgba(234, 179, 8, 0.7);
|
||||
}
|
||||
888
archive/js/app.js
Normal file
888
archive/js/app.js
Normal file
|
|
@ -0,0 +1,888 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
// window.app is initialized in init.js. Reference shape (read-only docs):
|
||||
// directories[], groupingFolders[], transmittalFolders[], files[],
|
||||
// filteredFiles[], selectedFiles:Set, sourceMode ('local'|'http'),
|
||||
// isScanning, scanProgress,
|
||||
// columnFilters {trackingNumber,title,revisions}, columnFilterASTs {...},
|
||||
// groupingFilter, transmittalFilter,
|
||||
// enabledFolderTypes:Set('issued','received'),
|
||||
// sortField ('trackingNumber'), sortDirection ('asc'|'desc'),
|
||||
// selectedGroupingFolders:Set, selectedTransmittalFolders:Set,
|
||||
// collapsedDateGroups:Set, collapsedGroupingFolders:Set,
|
||||
// selectAllGroupingFolders:bool, selectAllTransmittals:bool,
|
||||
// availableModifiers:Set, selectedModifiers:Set, showSelectedOnly:bool
|
||||
|
||||
// Parse search terms from filter string
|
||||
function parseSearchTerms(filter) {
|
||||
if (!filter || !filter.trim()) return [];
|
||||
return filter.trim().toLowerCase().split(/\s+/);
|
||||
}
|
||||
|
||||
// Check if text matches all search terms (AND logic)
|
||||
function matchesSearchTerms(text, terms) {
|
||||
if (!terms || terms.length === 0) return true;
|
||||
return terms.every(term => text.includes(term));
|
||||
}
|
||||
|
||||
// Initialize application
|
||||
function initApp() {
|
||||
// Detect source mode from protocol
|
||||
window.app.sourceMode = (location.protocol === 'file:') ? 'local' : 'http';
|
||||
|
||||
if (window.app.sourceMode === 'local') {
|
||||
// Check File System Access API support (local mode only)
|
||||
if (!('showDirectoryPicker' in window)) {
|
||||
showUnsupportedBrowserMessage();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
window.app.modules.events.setupEventListeners();
|
||||
|
||||
// Set up file link handlers (event delegation)
|
||||
window.app.modules.table.setupFileLinkHandlers();
|
||||
|
||||
// Apply source-mode-specific UI adjustments
|
||||
applySourceModeUI();
|
||||
|
||||
// Restore filter/sort state from URL query string
|
||||
window.app.modules.urlState.restore();
|
||||
|
||||
// Initialize UI
|
||||
updateUI();
|
||||
|
||||
// Show initial sort indicator
|
||||
window.app.modules.table.updateSortIndicators();
|
||||
|
||||
if (window.app.sourceMode === 'http') {
|
||||
// Auto-connect to the server in HTTP mode
|
||||
autoConnectHttpSource();
|
||||
} else {
|
||||
// Show empty state if no directories (local mode)
|
||||
if (window.app.directories.length === 0) {
|
||||
showEmptyState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply UI differences based on source mode
|
||||
function applySourceModeUI() {
|
||||
// "Add Local Directory" button is always visible in both modes —
|
||||
// in HTTP mode the user can augment the online archive with local directories.
|
||||
}
|
||||
|
||||
// Auto-connect to the HTTP server
|
||||
// Derives the base URL from the current page's location
|
||||
async function autoConnectHttpSource() {
|
||||
var href = window.location.href;
|
||||
// Strip query string and fragment
|
||||
href = href.split('?')[0].split('#')[0];
|
||||
// Strip the filename to get the directory
|
||||
var lastSlash = href.lastIndexOf('/');
|
||||
var baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
|
||||
|
||||
// Check for projects that are in the URL filter but not accessible on the server
|
||||
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
|
||||
try {
|
||||
var resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' } });
|
||||
if (resp.ok) {
|
||||
var serverProjects = await resp.json();
|
||||
var accessibleNames = new Set(serverProjects.map(function(p) { return p.name; }));
|
||||
var missing = Array.from(window.app.projectFilter).filter(function(p) {
|
||||
return !accessibleNames.has(p);
|
||||
});
|
||||
if (missing.length > 0) {
|
||||
showProjectWarning(missing);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore — server may not support the project list API
|
||||
}
|
||||
}
|
||||
|
||||
await addHttpSource(baseUrl);
|
||||
}
|
||||
|
||||
// Add an HTTP source root (analogous to addDirectory() for local mode)
|
||||
async function addHttpSource(baseUrl) {
|
||||
// Derive a display name from the URL path
|
||||
var urlPath = baseUrl.replace(/\/$/, '');
|
||||
var rootName = urlPath.substring(urlPath.lastIndexOf('/') + 1) || urlPath;
|
||||
|
||||
// Check if already added
|
||||
var exists = window.app.directories.some(function(d) { return d.url === baseUrl; });
|
||||
if (exists) return;
|
||||
|
||||
window.app.directories.push({
|
||||
handle: null,
|
||||
name: rootName,
|
||||
path: rootName,
|
||||
url: baseUrl
|
||||
});
|
||||
|
||||
if (window.app.directories.length === 1) {
|
||||
hideEmptyState();
|
||||
}
|
||||
|
||||
await scanHttpSource(baseUrl, rootName);
|
||||
updateUI();
|
||||
}
|
||||
|
||||
// Scan an HTTP source root
|
||||
async function scanHttpSource(baseUrl, rootName) {
|
||||
window.app.isScanning = true;
|
||||
window.app.scanProgress = 'Connecting to server...';
|
||||
updateStatusBar();
|
||||
|
||||
var source = window.app.modules.source.createSource('http', { baseUrl: baseUrl });
|
||||
|
||||
var fileCount = 0;
|
||||
var callbacks = {
|
||||
onGroupingFolder: function(folder) {
|
||||
window.app.groupingFolders.push(folder);
|
||||
},
|
||||
onTransmittalFolder: function(folder) {
|
||||
window.app.transmittalFolders.push(folder);
|
||||
},
|
||||
onFile: function(file) {
|
||||
window.app.files.push(file);
|
||||
fileCount++;
|
||||
// Throttled progress update — don't update DOM on every file
|
||||
if (fileCount % 10 === 0) {
|
||||
window.app.scanProgress = 'Scanning\u2026 ' + fileCount + ' files found';
|
||||
updateStatusBar();
|
||||
}
|
||||
},
|
||||
onProgress: function() { /* no-op: parallel scan — spinner is enough */ }
|
||||
};
|
||||
|
||||
try {
|
||||
await source.scan(baseUrl, callbacks);
|
||||
|
||||
// Auto-select top-level party folders (shallowest depth)
|
||||
var groupingDepths = window.app.groupingFolders.map(function(f) { return f.path.split('/').length; });
|
||||
var minGroupingDepth = groupingDepths.length > 0 ? Math.min.apply(null, groupingDepths) : 1;
|
||||
window.app.groupingFolders.forEach(function(folder) {
|
||||
if (folder.path.split('/').length === minGroupingDepth) {
|
||||
window.app.selectedGroupingFolders.add(folder.path);
|
||||
}
|
||||
});
|
||||
|
||||
window.app.transmittalFolders.forEach(function(folder) {
|
||||
if (!isUnderHiddenFolderType(folder.path)) {
|
||||
window.app.selectedTransmittalFolders.add(folder.path);
|
||||
}
|
||||
});
|
||||
|
||||
ensureOutstandingTransmittal();
|
||||
// Auto-select Outstanding if selectAllTransmittals is active
|
||||
if (window.app.selectAllTransmittals) {
|
||||
window.app.selectedTransmittalFolders.add('__outstanding__');
|
||||
}
|
||||
|
||||
collectModifiers();
|
||||
updateUI();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
if (window.app.modules.presets) {
|
||||
window.app.modules.presets.init();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error scanning HTTP source:', err);
|
||||
showHttpErrorState(err.message);
|
||||
} finally {
|
||||
window.app.isScanning = false;
|
||||
window.app.scanProgress = '';
|
||||
updateStatusBar();
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the Outstanding virtual transmittal exists if there are any outstanding files.
|
||||
// Called after each scan completes. Idempotent — safe to call multiple times.
|
||||
function ensureOutstandingTransmittal() {
|
||||
const hasOutstanding = window.app.files.some(f => f.folderPath === '__outstanding__');
|
||||
const alreadyExists = window.app.transmittalFolders.some(f => f.path === '__outstanding__');
|
||||
if (hasOutstanding && !alreadyExists) {
|
||||
window.app.transmittalFolders.push({
|
||||
name: 'Outstanding',
|
||||
path: '__outstanding__',
|
||||
displayPath: 'Outstanding',
|
||||
handle: null,
|
||||
url: null,
|
||||
isVirtual: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show error state when HTTP server is unreachable
|
||||
function showHttpErrorState(message) {
|
||||
var el = document.getElementById('noDirectoryMessage');
|
||||
if (!el) return;
|
||||
var content = el.querySelector('.empty-state-content');
|
||||
if (content) {
|
||||
content.innerHTML =
|
||||
'<h2>Could not connect to server</h2>' +
|
||||
'<p>The archive browser could not retrieve the directory listing from the server.</p>' +
|
||||
'<p><strong>Error:</strong> ' + escapeHtml(message || 'Unknown error') + '</p>' +
|
||||
'<p>Ensure the server is running, CORS is not blocking the request, and Caddy\'s file browsing is enabled.</p>';
|
||||
}
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Show a warning banner listing projects in the URL filter that the user cannot access
|
||||
function showProjectWarning(missingProjects) {
|
||||
var el = document.getElementById('projectWarningBanner');
|
||||
if (!el || missingProjects.length === 0) return;
|
||||
var list = missingProjects.map(function(p) { return escapeHtml(p); }).join(', ');
|
||||
el.querySelector('.project-warning-text').innerHTML =
|
||||
'This link includes projects you don\'t have access to: <strong>' + list + '</strong>';
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function dismissProjectWarning() {
|
||||
var el = document.getElementById('projectWarningBanner');
|
||||
if (el) el.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Show unsupported browser message
|
||||
function showUnsupportedBrowserMessage() {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<h2>Browser Not Supported</h2>
|
||||
<p>This application requires a Chromium-based browser (Chrome, Edge, Brave) with File System Access API support.</p>
|
||||
<p>Please use one of these browsers to access the Archive Browser.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Show empty state
|
||||
function showEmptyState() {
|
||||
document.getElementById('noDirectoryMessage').classList.remove('hidden');
|
||||
document.querySelector('.main-container').style.display = 'none';
|
||||
// Keep header visible
|
||||
document.querySelector('.app-header').style.display = '';
|
||||
var refreshBtn = document.getElementById('refreshHeaderBtn');
|
||||
if (refreshBtn) { refreshBtn.classList.add('hidden'); }
|
||||
}
|
||||
|
||||
// Hide empty state
|
||||
function hideEmptyState() {
|
||||
document.getElementById('noDirectoryMessage').classList.add('hidden');
|
||||
document.querySelector('.main-container').style.display = '';
|
||||
var refreshBtn = document.getElementById('refreshHeaderBtn');
|
||||
if (refreshBtn) { refreshBtn.classList.remove('hidden'); }
|
||||
}
|
||||
|
||||
// Update UI based on current state
|
||||
function updateUI() {
|
||||
renderFolderTypeBar();
|
||||
renderFolderLists();
|
||||
window.app.modules.table.updateFileTable();
|
||||
updateStatusBar();
|
||||
}
|
||||
|
||||
// Render folder lists (rebuilds DOM)
|
||||
function renderFolderLists() {
|
||||
renderGroupingFolders();
|
||||
renderTransmittalFolders();
|
||||
}
|
||||
|
||||
// Check if a folder path is under a hidden folder type
|
||||
// Returns true if any path segment is a known folder type that is NOT currently enabled
|
||||
function isUnderHiddenFolderType(path) {
|
||||
const parts = path.toLowerCase().split('/');
|
||||
return parts.some(part =>
|
||||
window.app.FOLDER_TYPE_NAMES.includes(part) && !window.app.enabledFolderTypes.has(part)
|
||||
);
|
||||
}
|
||||
|
||||
// Get filtered grouping folders (single source of truth for filtering logic)
|
||||
function getFilteredGroupingFolders() {
|
||||
const filter = window.app.groupingFilter;
|
||||
|
||||
return window.app.groupingFolders.filter(folder => {
|
||||
if (isUnderHiddenFolderType(folder.path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!filter) return true;
|
||||
|
||||
const terms = parseSearchTerms(filter);
|
||||
return matchesSearchTerms(folder.name.toLowerCase(), terms);
|
||||
});
|
||||
}
|
||||
|
||||
// Render grouping folders as a flat list of party names (depth 1 only)
|
||||
function renderGroupingFolders() {
|
||||
const container = document.getElementById('groupingFoldersList');
|
||||
|
||||
// Get filtered grouping folders (uses shared filtering logic)
|
||||
const filteredFolders = getFilteredGroupingFolders();
|
||||
|
||||
// Only show top-level party folders (the shallowest depth among all grouping folders)
|
||||
const allDepths = window.app.groupingFolders.map(f => f.path.split('/').length);
|
||||
const minDepth = allDepths.length > 0 ? Math.min(...allDepths) : 1;
|
||||
const partyFolders = filteredFolders.filter(f => f.path.split('/').length === minDepth);
|
||||
|
||||
// Sort alphabetically
|
||||
partyFolders.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
// Build set of paths for quick lookup
|
||||
const partyPaths = new Set(partyFolders.map(f => f.path));
|
||||
|
||||
// If "Select All" mode is active, auto-select all visible party folders
|
||||
if (window.app.selectAllGroupingFolders) {
|
||||
window.app.selectedGroupingFolders.clear();
|
||||
partyFolders.forEach(f => window.app.selectedGroupingFolders.add(f.path));
|
||||
} else {
|
||||
// Remove selections for folders that are no longer visible
|
||||
for (const selectedPath of window.app.selectedGroupingFolders) {
|
||||
if (!partyPaths.has(selectedPath)) {
|
||||
window.app.selectedGroupingFolders.delete(selectedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync checkbox state
|
||||
const checkbox = document.getElementById('selectAllGroupingCheckbox');
|
||||
if (checkbox) checkbox.checked = window.app.selectAllGroupingFolders;
|
||||
|
||||
if (partyFolders.length === 0 && window.app.groupingFilter) {
|
||||
container.innerHTML = '<div class="folder-list-empty">No parties match your filter</div>';
|
||||
updateFolderSelectionState('groupingFoldersList');
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = partyFolders.map(folder => `
|
||||
<div class="folder-item ${window.app.selectedGroupingFolders.has(folder.path) ? 'selected' : ''}"
|
||||
data-path="${escapeHtml(folder.path)}"
|
||||
data-folder-type="grouping">
|
||||
<span class="folder-item-name" title="${escapeHtml(folder.path)}">${escapeHtml(folder.name)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
updateFolderSelectionState('groupingFoldersList');
|
||||
}
|
||||
|
||||
// Render the global folder type toggle bar
|
||||
function renderFolderTypeBar() {
|
||||
const bar = document.getElementById('folderTypeBar');
|
||||
if (!bar) return;
|
||||
|
||||
const FOLDER_TYPE_LABELS = { mdl: 'MDL', incoming: 'Incoming', issued: 'Issued', received: 'Received' };
|
||||
bar.innerHTML = window.app.FOLDER_TYPE_NAMES.map(type => {
|
||||
const active = window.app.enabledFolderTypes.has(type);
|
||||
const label = FOLDER_TYPE_LABELS[type] || (type.charAt(0).toUpperCase() + type.slice(1));
|
||||
return `<button class="folder-type-toggle ${active ? 'active' : ''}"
|
||||
data-type="${type}"
|
||||
title="Toggle ${label} folders">${label}</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Toggle a folder type on/off globally
|
||||
function toggleFolderType(type) {
|
||||
if (window.app.enabledFolderTypes.has(type)) {
|
||||
window.app.enabledFolderTypes.delete(type);
|
||||
} else {
|
||||
window.app.enabledFolderTypes.add(type);
|
||||
}
|
||||
renderFolderTypeBar();
|
||||
renderGroupingFolders();
|
||||
renderTransmittalFolders();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
window.app.modules.urlState.push();
|
||||
}
|
||||
|
||||
// Returns true if an outstanding file's actualPath is under a selected grouping folder
|
||||
// that is itself visible (not hidden by folder type toggles).
|
||||
function outstandingFileIsVisible(file) {
|
||||
const selectedGrouping = window.app.selectedGroupingFolders;
|
||||
if (selectedGrouping.size === 0) return false;
|
||||
// The actualPath must not be under a hidden folder type
|
||||
if (isUnderHiddenFolderType(file.actualPath)) return false;
|
||||
// The actualPath must be at or under one of the selected grouping folder paths
|
||||
return Array.from(selectedGrouping).some(function(gPath) {
|
||||
return file.actualPath === gPath || file.actualPath.startsWith(gPath + '/');
|
||||
});
|
||||
}
|
||||
|
||||
// Returns true if any outstanding (non-transmittal) files exist under the currently
|
||||
// selected and visible grouping folders.
|
||||
function hasVisibleOutstandingFiles() {
|
||||
return window.app.files.some(function(f) {
|
||||
if (f.folderPath !== '__outstanding__') return false;
|
||||
return outstandingFileIsVisible(f);
|
||||
});
|
||||
}
|
||||
|
||||
// Returns true if a transmittal folder is under a selected party and an enabled folder type.
|
||||
// Handles both HTTP paths (party at depth 0) and local paths (party at depth 1+ due to root dir prefix).
|
||||
function transmittalIsUnderVisibleParty(folder) {
|
||||
const parts = folder.path.split('/');
|
||||
|
||||
// Find which segment is the party (the one that matches a selected grouping folder path prefix).
|
||||
// The party path is the selected grouping folder path, so check prefix matches.
|
||||
for (const partyPath of window.app.selectedGroupingFolders) {
|
||||
const partyParts = partyPath.split('/');
|
||||
const partyDepth = partyParts.length; // e.g. 1 for HTTP ("ACME"), 2 for local ("RootDir/ACME")
|
||||
|
||||
// Check that folder path starts with partyPath
|
||||
if (!folder.path.startsWith(partyPath + '/') && folder.path !== partyPath) continue;
|
||||
|
||||
// The segment immediately after partyPath is either a folder type or the transmittal itself
|
||||
const remainder = folder.path.substring(partyPath.length + 1); // e.g. "Issued/2025-01-01_..." or "2025-01-01_..."
|
||||
const remainderParts = remainder.split('/');
|
||||
|
||||
if (remainderParts.length >= 2) {
|
||||
// There's a folder type segment before the transmittal
|
||||
const folderType = remainderParts[0].toLowerCase();
|
||||
if (window.app.FOLDER_TYPE_NAMES.includes(folderType)) {
|
||||
// Must be an enabled type
|
||||
return window.app.enabledFolderTypes.has(folderType);
|
||||
}
|
||||
// Unknown folder type — treat as visible
|
||||
return true;
|
||||
}
|
||||
|
||||
// Transmittal is directly under the party (no folder type level) — always show
|
||||
return true;
|
||||
}
|
||||
|
||||
// Party not selected
|
||||
return false;
|
||||
}
|
||||
|
||||
// Render transmittal folders (rebuilds DOM)
|
||||
function renderTransmittalFolders() {
|
||||
const container = document.getElementById('transmittalFoldersList');
|
||||
const filter = window.app.transmittalFilter;
|
||||
|
||||
// Filter transmittal folders based on grouping selection and name filter
|
||||
const filteredFolders = window.app.transmittalFolders.filter(folder => {
|
||||
// Outstanding virtual transmittal: include if there are visible outstanding files
|
||||
if (folder.path === '__outstanding__') {
|
||||
if (!hasVisibleOutstandingFiles()) return false;
|
||||
// Apply name filter to "Outstanding" label too
|
||||
if (filter && filter.trim()) {
|
||||
const terms = parseSearchTerms(filter.trim());
|
||||
if (!matchesSearchTerms('outstanding', terms)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check name filter
|
||||
let matchesFilter = true;
|
||||
if (filter && filter.trim()) {
|
||||
const terms = parseSearchTerms(filter.trim());
|
||||
const folderText = folder.name.toLowerCase();
|
||||
matchesFilter = matchesSearchTerms(folderText, terms);
|
||||
}
|
||||
|
||||
// If no grouping folders exist at all, show all transmittal folders (flat structure)
|
||||
if (window.app.groupingFolders.length === 0) {
|
||||
return matchesFilter;
|
||||
}
|
||||
|
||||
// If grouping folders exist but none are selected, show nothing
|
||||
if (window.app.selectedGroupingFolders.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check party + folder type visibility
|
||||
return matchesFilter && transmittalIsUnderVisibleParty(folder);
|
||||
});
|
||||
|
||||
// Sort regular transmittal folders by date (newest first); Outstanding handled separately
|
||||
const regularFolders = filteredFolders.filter(f => f.path !== '__outstanding__');
|
||||
regularFolders.sort((a, b) => b.name.localeCompare(a.name));
|
||||
|
||||
const showOutstanding = filteredFolders.some(f => f.path === '__outstanding__');
|
||||
|
||||
// Build set of visible folder paths (for Select All and deselection logic)
|
||||
const filteredPaths = new Set(filteredFolders.map(f => f.path));
|
||||
|
||||
// If "Select All" mode is active, auto-select all visible transmittal folders
|
||||
if (window.app.selectAllTransmittals) {
|
||||
window.app.selectedTransmittalFolders.clear();
|
||||
filteredFolders.forEach(f => window.app.selectedTransmittalFolders.add(f.path));
|
||||
} else {
|
||||
// Remove selections for folders that are now filtered out
|
||||
for (const selectedPath of window.app.selectedTransmittalFolders) {
|
||||
if (!filteredPaths.has(selectedPath)) {
|
||||
window.app.selectedTransmittalFolders.delete(selectedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync checkbox state
|
||||
const checkbox = document.getElementById('selectAllTransmittalsCheckbox');
|
||||
if (checkbox) checkbox.checked = window.app.selectAllTransmittals;
|
||||
|
||||
// Group regular folders by date
|
||||
const foldersByDate = new Map();
|
||||
regularFolders.forEach(folder => {
|
||||
const match = folder.name.match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
const date = match ? match[1] : 'Unknown';
|
||||
if (!foldersByDate.has(date)) {
|
||||
foldersByDate.set(date, []);
|
||||
}
|
||||
foldersByDate.get(date).push(folder);
|
||||
});
|
||||
|
||||
// Build HTML
|
||||
let html = '';
|
||||
|
||||
// Outstanding virtual transmittal — pinned at top
|
||||
if (showOutstanding) {
|
||||
const isSelected = window.app.selectedTransmittalFolders.has('__outstanding__');
|
||||
html += `
|
||||
<div class="folder-item outstanding-transmittal ${isSelected ? 'selected' : ''}"
|
||||
data-path="__outstanding__"
|
||||
data-folder-type="transmittal"
|
||||
title="Files in non-transmittal folders under selected grouping folders">
|
||||
<div class="transmittal-folder-content">
|
||||
<div class="transmittal-first-line outstanding-label">⋯ Outstanding</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Regular date-grouped folders
|
||||
for (const [date, folders] of foldersByDate) {
|
||||
const isCollapsed = window.app.collapsedDateGroups.has(date);
|
||||
const folderCount = folders.length;
|
||||
|
||||
html += `
|
||||
<div class="date-group-header" data-date="${escapeHtml(date)}">
|
||||
<span class="date-group-toggle">${isCollapsed ? '▶' : '▼'}</span>
|
||||
<span class="date-group-date">${escapeHtml(date)}</span>
|
||||
<span class="date-group-count">(${folderCount})</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!isCollapsed) {
|
||||
for (const folder of folders) {
|
||||
const match = folder.name.match(/^\d{4}-\d{2}-\d{2}_([^_\s]+)\s*\(([^)]+)\)\s*-\s*(.+)$/);
|
||||
let firstLine = folder.name;
|
||||
let secondLine = '';
|
||||
|
||||
if (match) {
|
||||
const [, tracking, status, title] = match;
|
||||
firstLine = `${tracking} • ${status}`;
|
||||
secondLine = title;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="folder-item ${window.app.selectedTransmittalFolders.has(folder.path) ? 'selected' : ''}"
|
||||
data-path="${escapeHtml(folder.path)}"
|
||||
data-folder-type="transmittal"
|
||||
title="${escapeHtml(folder.path)}">
|
||||
<div class="transmittal-folder-content">
|
||||
<div class="transmittal-first-line">${escapeHtml(firstLine)}</div>
|
||||
${secondLine ? `<div class="transmittal-second-line">${escapeHtml(secondLine)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredFolders.length === 0 && window.app.transmittalFilter) {
|
||||
container.innerHTML = '<div class="folder-list-empty">No folders match your filter</div>';
|
||||
updateFolderSelectionState('transmittalFoldersList');
|
||||
window.app.modules.events.updateToggleAllIcon();
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Ensure selection state is visually reflected after DOM rebuild
|
||||
updateFolderSelectionState('transmittalFoldersList');
|
||||
|
||||
// Update the toggle all icon to reflect current state
|
||||
window.app.modules.events.updateToggleAllIcon();
|
||||
}
|
||||
|
||||
|
||||
// Update status bar
|
||||
function updateStatusBar() {
|
||||
const fileCountEl = document.getElementById('fileCount');
|
||||
const selectedCountEl = document.getElementById('selectedCount');
|
||||
|
||||
// Before any directory is loaded, show a hint instead of "0 files"
|
||||
if (window.app.directories.length === 0 && !window.app.isScanning) {
|
||||
fileCountEl.textContent = 'Select a directory to begin';
|
||||
selectedCountEl.textContent = '';
|
||||
document.getElementById('scanStatus').textContent = '';
|
||||
var spinner2 = document.getElementById('scanSpinner');
|
||||
if (spinner2) spinner2.classList.add('hidden');
|
||||
document.getElementById('downloadSelectedBtn').disabled = true;
|
||||
document.getElementById('exportCsvBtn').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Count unique tracking numbers
|
||||
const trackingNumbers = new Set(window.app.filteredFiles.map(f => f.trackingNumber));
|
||||
const trackingCount = trackingNumbers.size;
|
||||
const fileCount = window.app.filteredFiles.length;
|
||||
|
||||
// Count files with path errors
|
||||
const pathErrorCount = window.app.filteredFiles.filter(f => f.hasPathError).length;
|
||||
|
||||
// Format: "X tracking numbers, Y files" + optional path error warning
|
||||
let countText = `${trackingCount} tracking number${trackingCount !== 1 ? 's' : ''}, ${fileCount} file${fileCount !== 1 ? 's' : ''}`;
|
||||
if (pathErrorCount > 0) {
|
||||
countText += ` (⚠️ ${pathErrorCount} inaccessible)`;
|
||||
}
|
||||
|
||||
fileCountEl.textContent = countText;
|
||||
selectedCountEl.textContent = `${window.app.selectedFiles.size} selected`;
|
||||
document.getElementById('scanStatus').textContent = window.app.scanProgress;
|
||||
var spinner = document.getElementById('scanSpinner');
|
||||
if (spinner) { spinner.classList.toggle('hidden', !window.app.isScanning); }
|
||||
|
||||
// Disable action buttons when nothing is selected
|
||||
const noneSelected = window.app.selectedFiles.size === 0;
|
||||
document.getElementById('downloadSelectedBtn').disabled = noneSelected;
|
||||
document.getElementById('exportCsvBtn').disabled = noneSelected;
|
||||
}
|
||||
|
||||
// Escape HTML for safe insertion
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update folder selection visual state without rebuilding DOM
|
||||
* This is more efficient than re-rendering when only selection changes
|
||||
* @param {string} containerId - 'groupingFoldersList' or 'transmittalFoldersList'
|
||||
*/
|
||||
function updateFolderSelectionState(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.warn(`Container not found: ${containerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedSet = containerId === 'groupingFoldersList' ?
|
||||
window.app.selectedGroupingFolders :
|
||||
window.app.selectedTransmittalFolders;
|
||||
|
||||
// Update selected class on existing elements
|
||||
container.querySelectorAll('.folder-item').forEach(item => {
|
||||
const path = item.getAttribute('data-path');
|
||||
if (path) {
|
||||
item.classList.toggle('selected', selectedSet.has(path));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract modifier type from revision string (e.g., "2+B1" -> "+B", "2" -> "base")
|
||||
function getModifierType(revision) {
|
||||
if (!revision) return 'base';
|
||||
const match = revision.match(/\+([A-Za-z])/);
|
||||
return match ? '+' + match[1].toUpperCase() : 'base';
|
||||
}
|
||||
|
||||
// Collect all unique modifiers from files
|
||||
function collectModifiers() {
|
||||
window.app.availableModifiers.clear();
|
||||
|
||||
window.app.files.forEach(file => {
|
||||
const modType = getModifierType(file.revision);
|
||||
window.app.availableModifiers.add(modType);
|
||||
});
|
||||
|
||||
// Select all by default
|
||||
window.app.selectedModifiers = new Set(window.app.availableModifiers);
|
||||
|
||||
// Update the dropdown UI
|
||||
renderModifierDropdown();
|
||||
}
|
||||
|
||||
// Render the modifier dropdown options
|
||||
function renderModifierDropdown() {
|
||||
const list = document.getElementById('modifierFilterList');
|
||||
if (!list) return;
|
||||
|
||||
// Sort modifiers: "base" first, then alphabetically
|
||||
const sorted = Array.from(window.app.availableModifiers).sort((a, b) => {
|
||||
if (a === 'base') return -1;
|
||||
if (b === 'base') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
let html = '';
|
||||
sorted.forEach(mod => {
|
||||
const checked = window.app.selectedModifiers.has(mod) ? 'checked' : '';
|
||||
const label = mod === 'base' ? 'Base (no modifier)' : mod;
|
||||
const labelClass = mod === 'base' ? 'modifier-base' : 'modifier-type';
|
||||
html += `
|
||||
<div class="modifier-filter-item">
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
data-modifier="${mod}"
|
||||
${checked}
|
||||
onchange="toggleModifierFilter('${mod}')">
|
||||
<span class="${labelClass}">${label}</span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
list.innerHTML = html;
|
||||
updateModifierSelectAll();
|
||||
updateModifierButtonLabel();
|
||||
}
|
||||
|
||||
// Toggle a specific modifier filter
|
||||
function toggleModifierFilter(mod) {
|
||||
if (window.app.selectedModifiers.has(mod)) {
|
||||
window.app.selectedModifiers.delete(mod);
|
||||
} else {
|
||||
window.app.selectedModifiers.add(mod);
|
||||
}
|
||||
updateModifierSelectAll();
|
||||
updateModifierButtonLabel();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
}
|
||||
|
||||
// Toggle all modifiers
|
||||
function toggleAllModifiers(selectAll) {
|
||||
if (selectAll) {
|
||||
window.app.selectedModifiers = new Set(window.app.availableModifiers);
|
||||
} else {
|
||||
window.app.selectedModifiers.clear();
|
||||
}
|
||||
renderModifierDropdown();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
}
|
||||
|
||||
// Update the "Select All" checkbox state
|
||||
function updateModifierSelectAll() {
|
||||
const selectAllCheckbox = document.getElementById('modifierSelectAll');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = window.app.selectedModifiers.size === window.app.availableModifiers.size;
|
||||
selectAllCheckbox.indeterminate = window.app.selectedModifiers.size > 0 &&
|
||||
window.app.selectedModifiers.size < window.app.availableModifiers.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Update button label to show filter status
|
||||
function updateModifierButtonLabel() {
|
||||
const btn = document.getElementById('modifierFilterBtn');
|
||||
if (!btn) return;
|
||||
|
||||
const total = window.app.availableModifiers.size;
|
||||
const selected = window.app.selectedModifiers.size;
|
||||
|
||||
if (selected === total) {
|
||||
btn.textContent = 'Modifiers ▼';
|
||||
} else if (selected === 0) {
|
||||
btn.textContent = 'Modifiers (none) ▼';
|
||||
} else {
|
||||
btn.textContent = `Modifiers (${selected}/${total}) ▼`;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle modifier dropdown visibility
|
||||
function toggleModifierDropdown() {
|
||||
const dropdown = document.getElementById('modifierFilterDropdown');
|
||||
dropdown.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
// Update the Folders icon button state based on active visibility toggles
|
||||
function updateFolderVisibilityBtnLabel() {
|
||||
// replaced by renderFolderTypeBar()
|
||||
}
|
||||
|
||||
// Check if a file passes the modifier filter
|
||||
function filePassesModifierFilter(file) {
|
||||
const modType = getModifierType(file.revision);
|
||||
return window.app.selectedModifiers.has(modType);
|
||||
}
|
||||
|
||||
// Toggle filter to show only selected files
|
||||
function toggleFilterSelected() {
|
||||
window.app.showSelectedOnly = !window.app.showSelectedOnly;
|
||||
|
||||
// Update button visual state and label
|
||||
const btn = document.getElementById('filterSelectedBtn');
|
||||
if (window.app.showSelectedOnly) {
|
||||
btn.classList.add('btn-active');
|
||||
btn.textContent = 'Show All';
|
||||
} else {
|
||||
btn.classList.remove('btn-active');
|
||||
btn.textContent = 'Filter Selected';
|
||||
}
|
||||
|
||||
window.app.modules.filtering.applyFilters();
|
||||
}
|
||||
// Register with module system
|
||||
window.app.modules.app = {
|
||||
updateUI,
|
||||
updateStatusBar,
|
||||
escapeHtml,
|
||||
updateFolderSelectionState,
|
||||
getModifierType,
|
||||
collectModifiers,
|
||||
renderModifierDropdown,
|
||||
toggleModifierFilter,
|
||||
toggleAllModifiers,
|
||||
updateModifierSelectAll,
|
||||
updateModifierButtonLabel,
|
||||
toggleModifierDropdown,
|
||||
updateFolderVisibilityBtnLabel,
|
||||
filePassesModifierFilter,
|
||||
toggleFilterSelected,
|
||||
isUnderHiddenFolderType,
|
||||
ensureOutstandingTransmittal,
|
||||
showHttpErrorState,
|
||||
showUnsupportedBrowserMessage,
|
||||
showProjectWarning,
|
||||
dismissProjectWarning,
|
||||
showEmptyState,
|
||||
hideEmptyState,
|
||||
addHttpSource,
|
||||
scanHttpSource,
|
||||
renderGroupingFolders,
|
||||
renderTransmittalFolders,
|
||||
renderFolderTypeBar,
|
||||
toggleFolderType,
|
||||
outstandingFileIsVisible,
|
||||
hasVisibleOutstandingFiles,
|
||||
transmittalIsUnderVisibleParty,
|
||||
renderFolderLists,
|
||||
getFilteredGroupingFolders,
|
||||
showProjectWarning,
|
||||
dismissProjectWarning,
|
||||
};
|
||||
|
||||
// Expose key functions on window for inline HTML handlers
|
||||
window.initApp = initApp;
|
||||
window.toggleFileSelection = function(id) { window.app.modules.table.toggleFileSelection(id); };
|
||||
window.sortTable = function(f) { window.app.modules.table.sortTable(f); };
|
||||
window.confirmTransmittal = function() { window.app.modules.dragDrop.confirmTransmittal(); };
|
||||
window.toggleModifierFilter = toggleModifierFilter;
|
||||
window.toggleFilterSelected = toggleFilterSelected;
|
||||
window.toggleFolderType = toggleFolderType;
|
||||
window.toggleGroupingFolder = function(p, r) { window.app.modules.events.toggleGroupingFolder(p, r); };
|
||||
window.toggleDateGroup = function(d) { window.app.modules.events.toggleDateGroup(d); };
|
||||
window.toggleAllDateGroups = function() { window.app.modules.events.toggleAllDateGroups(); };
|
||||
window.selectAllVisibleFolders = function(t) { window.app.modules.events.selectAllVisibleFolders(t); };
|
||||
window.removeDirectory = function(n) { window.app.modules.directory.removeDirectory(n); };
|
||||
window.dismissProjectWarning = dismissProjectWarning;
|
||||
window.verifyFileIntegrity = function(id) { window.app.modules.hash.verifyFileIntegrity(id); };
|
||||
window.showProjectWarning = showProjectWarning;
|
||||
window.dismissProjectWarning = dismissProjectWarning;
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
|
||||
})();
|
||||
188
archive/js/directory.js
Normal file
188
archive/js/directory.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
// Directory selection and scanning functionality
|
||||
|
||||
// Add directory
|
||||
async function addDirectory() {
|
||||
try {
|
||||
const dirHandle = await window.showDirectoryPicker();
|
||||
|
||||
// Check if already added
|
||||
const exists = window.app.directories.some(d => d.name === dirHandle.name);
|
||||
if (exists) {
|
||||
alert('This directory has already been added.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to directories
|
||||
window.app.directories.push({
|
||||
handle: dirHandle,
|
||||
name: dirHandle.name,
|
||||
path: dirHandle.name // Root path
|
||||
});
|
||||
|
||||
// Hide empty state if this is the first directory
|
||||
if (window.app.directories.length === 1) {
|
||||
window.app.modules.app.hideEmptyState();
|
||||
}
|
||||
|
||||
// Scan the new directory
|
||||
await scanDirectory(dirHandle, dirHandle.name);
|
||||
|
||||
window.app.modules.app.updateUI();
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Error selecting directory:', err);
|
||||
alert('Error selecting directory: ' + err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan directory recursively (local mode — delegates to local source in source.js)
|
||||
async function scanDirectory(dirHandle, path) {
|
||||
window.app.isScanning = true;
|
||||
window.app.scanProgress = 'Scanning ' + path + '...';
|
||||
window.app.modules.app.updateStatusBar();
|
||||
|
||||
const source = window.app.modules.source.createSource('local', {});
|
||||
|
||||
const callbacks = {
|
||||
onGroupingFolder: function(folder) {
|
||||
window.app.groupingFolders.push(folder);
|
||||
},
|
||||
onTransmittalFolder: function(folder) {
|
||||
window.app.transmittalFolders.push(folder);
|
||||
},
|
||||
onFile: function(file) {
|
||||
window.app.files.push(file);
|
||||
},
|
||||
onProgress: function(message) {
|
||||
window.app.scanProgress = message;
|
||||
window.app.modules.app.updateStatusBar();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await source.scan(dirHandle, callbacks);
|
||||
|
||||
// Only auto-select top-level party folders (shallowest depth)
|
||||
const groupingDepths = window.app.groupingFolders.map(f => f.path.split('/').length);
|
||||
const minGroupingDepth = groupingDepths.length > 0 ? Math.min(...groupingDepths) : 1;
|
||||
window.app.groupingFolders.forEach(folder => {
|
||||
if (folder.path.split('/').length === minGroupingDepth) {
|
||||
window.app.selectedGroupingFolders.add(folder.path);
|
||||
}
|
||||
});
|
||||
|
||||
window.app.transmittalFolders.forEach(folder => {
|
||||
if (!window.app.modules.app.isUnderHiddenFolderType(folder.path)) {
|
||||
window.app.selectedTransmittalFolders.add(folder.path);
|
||||
}
|
||||
});
|
||||
|
||||
window.app.modules.app.ensureOutstandingTransmittal();
|
||||
// Auto-select Outstanding if selectAllTransmittals is active
|
||||
if (window.app.selectAllTransmittals) {
|
||||
window.app.selectedTransmittalFolders.add('__outstanding__');
|
||||
}
|
||||
|
||||
window.app.modules.app.collectModifiers();
|
||||
window.app.modules.app.updateUI();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
if (window.app.modules.presets) {
|
||||
window.app.modules.presets.init();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error scanning directory:', err);
|
||||
alert('Error scanning directory: ' + err.message);
|
||||
} finally {
|
||||
window.app.isScanning = false;
|
||||
window.app.scanProgress = '';
|
||||
window.app.modules.app.updateStatusBar();
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh all directories
|
||||
async function refreshDirectories() {
|
||||
// Clear existing data
|
||||
window.app.groupingFolders = [];
|
||||
window.app.transmittalFolders = [];
|
||||
window.app.files = [];
|
||||
window.app.filteredFiles = [];
|
||||
|
||||
if (window.app.sourceMode === 'http') {
|
||||
// Re-scan all HTTP sources
|
||||
const dirs = window.app.directories.slice();
|
||||
window.app.directories = [];
|
||||
for (const dir of dirs) {
|
||||
await window.app.modules.app.addHttpSource(dir.url);
|
||||
}
|
||||
} else {
|
||||
// Re-scan all local directories
|
||||
for (const dir of window.app.directories) {
|
||||
await scanDirectory(dir.handle, dir.name);
|
||||
}
|
||||
}
|
||||
|
||||
window.app.modules.app.updateUI();
|
||||
}
|
||||
|
||||
// Remove directory
|
||||
function removeDirectory(dirName) {
|
||||
const index = window.app.directories.findIndex(d => d.name === dirName);
|
||||
if (index !== -1) {
|
||||
window.app.directories.splice(index, 1);
|
||||
|
||||
// Remove associated folders and files
|
||||
window.app.groupingFolders = window.app.groupingFolders.filter(f =>
|
||||
!f.path.startsWith(dirName)
|
||||
);
|
||||
window.app.transmittalFolders = window.app.transmittalFolders.filter(f =>
|
||||
!f.path.startsWith(dirName)
|
||||
);
|
||||
window.app.files = window.app.files.filter(f =>
|
||||
!f.path.startsWith(dirName)
|
||||
);
|
||||
|
||||
// Clean up the Outstanding virtual transmittal if no outstanding files remain
|
||||
const hasAnyOutstanding = window.app.files.some(f => f.folderPath === '__outstanding__');
|
||||
if (!hasAnyOutstanding) {
|
||||
window.app.transmittalFolders = window.app.transmittalFolders.filter(f => f.path !== '__outstanding__');
|
||||
window.app.selectedTransmittalFolders.delete('__outstanding__');
|
||||
}
|
||||
|
||||
// Show empty state if no directories left
|
||||
if (window.app.directories.length === 0) {
|
||||
window.app.modules.app.showEmptyState();
|
||||
}
|
||||
|
||||
window.app.modules.app.updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
// Request permission for directory
|
||||
async function requestPermission(dirHandle) {
|
||||
const options = { mode: 'read' };
|
||||
|
||||
// Check current permission state
|
||||
if ((await dirHandle.queryPermission(options)) === 'granted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Request permission
|
||||
if ((await dirHandle.requestPermission(options)) === 'granted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
window.app.modules.directory = {
|
||||
addDirectory,
|
||||
scanDirectory,
|
||||
refreshDirectories,
|
||||
removeDirectory,
|
||||
requestPermission
|
||||
};
|
||||
|
||||
})();
|
||||
280
archive/js/drag-drop.js
Normal file
280
archive/js/drag-drop.js
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
// Drag and drop functionality
|
||||
|
||||
let draggedFiles = [];
|
||||
let targetGroupingFolder = null;
|
||||
|
||||
// Setup drag and drop
|
||||
function setupDragAndDrop() {
|
||||
// Prevent default drag behaviors
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
document.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
// Highlight drop zones
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
document.addEventListener(eventName, highlight, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
document.addEventListener(eventName, unhighlight, false);
|
||||
});
|
||||
|
||||
// Handle drops on grouping folders (for creating transmittals)
|
||||
document.getElementById('groupingFoldersList').addEventListener('drop', handleDrop, false);
|
||||
|
||||
// Handle drops on the main app area (for adding directories)
|
||||
document.getElementById('app').addEventListener('drop', handleAppDrop, false);
|
||||
}
|
||||
|
||||
// Prevent default behaviors
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Highlight drop zone
|
||||
function highlight(e) {
|
||||
const folderItem = e.target.closest('.folder-item');
|
||||
if (folderItem && folderItem.parentElement.id === 'groupingFoldersList') {
|
||||
folderItem.classList.add('drag-over');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove highlight
|
||||
function unhighlight(e) {
|
||||
document.querySelectorAll('.drag-over').forEach(item => {
|
||||
item.classList.remove('drag-over');
|
||||
});
|
||||
}
|
||||
|
||||
// Handle directory drop on main app area (for adding directories)
|
||||
async function handleAppDrop(e) {
|
||||
// Check if this is a drop on a grouping folder (handled separately)
|
||||
const folderItem = e.target.closest('.folder-item');
|
||||
if (folderItem && folderItem.parentElement.id === 'groupingFoldersList') {
|
||||
return; // Let handleDrop handle this
|
||||
}
|
||||
|
||||
// Check if dataTransfer has directory items
|
||||
const items = e.dataTransfer.items;
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
// Process each dropped item
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
// Check if it's a directory using the File System Access API
|
||||
if (item.kind === 'file') {
|
||||
const entry = await item.getAsFileSystemHandle();
|
||||
|
||||
if (entry && entry.kind === 'directory') {
|
||||
// Check if already added
|
||||
const exists = window.app.directories.some(d => d.name === entry.name);
|
||||
if (exists) {
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add to directories
|
||||
window.app.directories.push({
|
||||
handle: entry,
|
||||
name: entry.name,
|
||||
path: entry.name
|
||||
});
|
||||
|
||||
// Hide empty state if this is the first directory
|
||||
if (window.app.directories.length === 1) {
|
||||
window.app.modules.app.hideEmptyState();
|
||||
}
|
||||
|
||||
// Scan the new directory
|
||||
await window.app.modules.directory.scanDirectory(entry, entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI after processing all dropped directories
|
||||
if (window.app.directories.length > 0) {
|
||||
window.app.modules.app.updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file drop on grouping folder (for creating transmittals)
|
||||
async function handleDrop(e) {
|
||||
const folderItem = e.target.closest('.folder-item');
|
||||
if (!folderItem) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Find the grouping folder
|
||||
const folderPath = folderItem.getAttribute('data-path');
|
||||
const groupingFolder = window.app.groupingFolders.find(f => f.path === folderPath);
|
||||
if (!groupingFolder) return;
|
||||
|
||||
targetGroupingFolder = groupingFolder;
|
||||
draggedFiles = files;
|
||||
|
||||
// Show transmittal creation dialog
|
||||
showTransmittalDialog();
|
||||
}
|
||||
|
||||
// Show transmittal creation dialog
|
||||
function showTransmittalDialog() {
|
||||
const modal = document.getElementById('dropModal');
|
||||
|
||||
// Generate default transmittal name
|
||||
const today = new Date();
|
||||
const dateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
|
||||
// Try to extract tracking number from first file
|
||||
let trackingNumber = 'TRACKING';
|
||||
let title = 'Transmittal';
|
||||
|
||||
if (draggedFiles.length > 0) {
|
||||
const firstFile = draggedFiles[0];
|
||||
const parsed = zddc.parseFilename(firstFile.name) || {};
|
||||
if (parsed.trackingNumber) {
|
||||
trackingNumber = parsed.trackingNumber;
|
||||
}
|
||||
if (parsed.title) {
|
||||
title = parsed.title;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultName = `${dateStr}_${trackingNumber} (IFI) - ${title}`;
|
||||
document.getElementById('transmittalName').value = defaultName;
|
||||
|
||||
// Show file preview
|
||||
updateFilePreview();
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Update file preview in dialog
|
||||
function updateFilePreview() {
|
||||
const tbody = document.getElementById('filesPreviewBody');
|
||||
|
||||
const rows = draggedFiles.map(file => {
|
||||
const parsed = zddc.parseFilename(file.name) || {};
|
||||
|
||||
// Generate ZDDC-compliant name
|
||||
let newName = file.name;
|
||||
if (parsed.trackingNumber) {
|
||||
newName = `${parsed.trackingNumber}_${parsed.revision || 'A'} (${parsed.status || 'IFI'}) - ${parsed.title}.${parsed.extension}`;
|
||||
}
|
||||
|
||||
const isValid = !!parsed.trackingNumber;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${window.app.modules.app.escapeHtml(file.name)}</td>
|
||||
<td>
|
||||
<input type="text"
|
||||
class="form-input"
|
||||
value="${window.app.modules.app.escapeHtml(newName)}"
|
||||
data-original="${window.app.modules.app.escapeHtml(file.name)}"
|
||||
style="width: 100%;">
|
||||
</td>
|
||||
<td style="color: ${isValid ? 'green' : 'red'};">
|
||||
${isValid ? '✓ Valid' : '✗ Invalid'}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
tbody.innerHTML = rows;
|
||||
}
|
||||
|
||||
// Confirm transmittal creation
|
||||
async function confirmTransmittal() {
|
||||
const transmittalName = document.getElementById('transmittalName').value.trim();
|
||||
|
||||
// Validate transmittal folder name
|
||||
if (!window.app.modules.parser.isTransmittalFolder(transmittalName)) {
|
||||
alert('Invalid transmittal folder name. Must follow format: YYYY-MM-DD_TRACKINGNUMBER (STATUS) - TITLE');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create transmittal folder
|
||||
const transmittalHandle = await targetGroupingFolder.handle.getDirectoryHandle(transmittalName, { create: true });
|
||||
|
||||
// Get file mappings from preview
|
||||
const fileMappings = [];
|
||||
const inputs = document.querySelectorAll('#filesPreviewBody input');
|
||||
inputs.forEach((input, index) => {
|
||||
fileMappings.push({
|
||||
originalFile: draggedFiles[index],
|
||||
newName: input.value.trim()
|
||||
});
|
||||
});
|
||||
|
||||
// Save files with new names
|
||||
for (const mapping of fileMappings) {
|
||||
const fileHandle = await transmittalHandle.getFileHandle(mapping.newName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(mapping.originalFile);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
// Close modal
|
||||
document.getElementById('dropModal').classList.add('hidden');
|
||||
|
||||
// Refresh to show new files
|
||||
await window.app.modules.directory.refreshDirectories();
|
||||
|
||||
alert(`Transmittal created successfully with ${fileMappings.length} files.`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error creating transmittal:', err);
|
||||
alert('Error creating transmittal: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag and drop on table rows (for metadata copy)
|
||||
function setupTableRowDragDrop() {
|
||||
document.getElementById('filesTableBody').addEventListener('dragover', (e) => {
|
||||
const tr = e.target.closest('tr');
|
||||
if (tr) {
|
||||
e.preventDefault();
|
||||
tr.classList.add('drag-over');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('filesTableBody').addEventListener('dragleave', (e) => {
|
||||
const tr = e.target.closest('tr');
|
||||
if (tr) {
|
||||
tr.classList.remove('drag-over');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('filesTableBody').addEventListener('drop', async (e) => {
|
||||
const tr = e.target.closest('tr');
|
||||
if (!tr) return;
|
||||
|
||||
tr.classList.remove('drag-over');
|
||||
|
||||
// Get tracking number and title from the row
|
||||
const trackingNumber = tr.querySelector('td[data-field="trackingNumber"]').textContent;
|
||||
const title = tr.querySelector('td[data-field="title"]').textContent;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
// For table row drops, just copy metadata
|
||||
alert(`Would copy metadata:\nTracking Number: ${trackingNumber}\nTitle: ${title}\n\nTo ${files.length} file(s)`);
|
||||
});
|
||||
}
|
||||
|
||||
window.app.modules.dragDrop = {
|
||||
setupDragAndDrop,
|
||||
showTransmittalDialog,
|
||||
updateFilePreview,
|
||||
confirmTransmittal,
|
||||
setupTableRowDragDrop
|
||||
};
|
||||
|
||||
})();
|
||||
592
archive/js/events.js
Normal file
592
archive/js/events.js
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
// Event handling
|
||||
|
||||
// Set up all event listeners
|
||||
function setupEventListeners() {
|
||||
// Header buttons
|
||||
document.getElementById('addDirectoryBtn').addEventListener('click', () => window.app.modules.directory.addDirectory());
|
||||
document.getElementById('refreshHeaderBtn').addEventListener('click', () => window.app.modules.directory.refreshDirectories());
|
||||
|
||||
// Content area buttons
|
||||
document.getElementById('filterSelectedBtn').addEventListener('click', () => window.app.modules.app.toggleFilterSelected());
|
||||
document.getElementById('downloadSelectedBtn').addEventListener('click', () => window.app.modules.export.downloadSelected());
|
||||
document.getElementById('exportCsvBtn').addEventListener('click', () => window.app.modules.export.exportCSV());
|
||||
|
||||
// Search and filter inputs
|
||||
document.getElementById('groupingFilter').addEventListener('input', (e) => {
|
||||
window.app.groupingFilter = e.target.value;
|
||||
e.target.classList.toggle('filter-active', e.target.value.length > 0);
|
||||
window.app.modules.app.updateUI();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
window.app.modules.urlState.push();
|
||||
});
|
||||
|
||||
document.getElementById('transmittalFilter').addEventListener('input', (e) => {
|
||||
window.app.transmittalFilter = e.target.value;
|
||||
e.target.classList.toggle('filter-active', e.target.value.length > 0);
|
||||
window.app.modules.app.updateUI();
|
||||
window.app.modules.filtering.applyFilters(); // Re-filter files when transmittal filter changes
|
||||
window.app.modules.urlState.push();
|
||||
});
|
||||
|
||||
// Select All Grouping Folders checkbox
|
||||
document.getElementById('selectAllGroupingCheckbox').addEventListener('change', (e) => {
|
||||
window.app.selectAllGroupingFolders = e.target.checked;
|
||||
window.app.modules.app.renderGroupingFolders();
|
||||
window.app.modules.app.renderTransmittalFolders();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
});
|
||||
|
||||
// Folder type toggle bar — global click delegation
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.folder-type-toggle');
|
||||
if (btn) {
|
||||
const type = btn.getAttribute('data-type');
|
||||
if (type) window.app.modules.app.toggleFolderType(type);
|
||||
}
|
||||
});
|
||||
|
||||
// Select All Transmittals checkbox
|
||||
document.getElementById('selectAllTransmittalsCheckbox').addEventListener('change', (e) => {
|
||||
window.app.selectAllTransmittals = e.target.checked;
|
||||
window.app.modules.app.renderTransmittalFolders();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
});
|
||||
|
||||
// Modifier filter dropdown
|
||||
document.getElementById('modifierFilterBtn').addEventListener('click', () => window.app.modules.app.toggleModifierDropdown());
|
||||
document.getElementById('modifierSelectAll').addEventListener('change', (e) => {
|
||||
window.app.modules.app.toggleAllModifiers(e.target.checked);
|
||||
});
|
||||
|
||||
// Close modifier dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const container = document.querySelector('.modifier-filter-container');
|
||||
const dropdown = document.getElementById('modifierFilterDropdown');
|
||||
if (container && dropdown && !container.contains(e.target)) {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Select all visible files checkbox
|
||||
document.getElementById('selectAllVisibleCheckbox').addEventListener('change', (e) => {
|
||||
e.stopPropagation();
|
||||
window.app.modules.table.toggleSelectAllVisible(e.target.checked);
|
||||
});
|
||||
|
||||
// Reset filters button
|
||||
document.getElementById('resetFiltersBtn').addEventListener('click', () => window.app.modules.filtering.clearFilters());
|
||||
|
||||
// Column filters — delegated from thead
|
||||
const thead = document.querySelector('thead');
|
||||
if (thead) {
|
||||
thead.addEventListener('input', (e) => {
|
||||
if (e.target.matches('.column-filter[data-filter-field]')) {
|
||||
const field = e.target.getAttribute('data-filter-field');
|
||||
const raw = e.target.value.trim();
|
||||
window.app.columnFilters[field] = raw;
|
||||
window.app.columnFilterASTs[field] = zddc.filter.parse(raw);
|
||||
|
||||
// Add/remove filter-active class based on non-empty value
|
||||
if (raw) {
|
||||
e.target.classList.add('filter-active');
|
||||
} else {
|
||||
e.target.classList.remove('filter-active');
|
||||
}
|
||||
|
||||
window.app.modules.filtering.applyFilters();
|
||||
window.app.modules.urlState.push();
|
||||
}
|
||||
});
|
||||
thead.addEventListener('keydown', (e) => {
|
||||
if (!e.target.matches('.column-filter[data-filter-field]')) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.target.value = '';
|
||||
e.target.classList.remove('filter-active');
|
||||
const field = e.target.getAttribute('data-filter-field');
|
||||
window.app.columnFilters[field] = '';
|
||||
window.app.columnFilterASTs[field] = null;
|
||||
window.app.modules.filtering.applyFilters();
|
||||
window.app.modules.urlState.push();
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Table sorting
|
||||
document.querySelectorAll('.sortable').forEach(th => {
|
||||
th.querySelector('.th-content').addEventListener('click', () => {
|
||||
const field = th.getAttribute('data-field');
|
||||
window.app.modules.table.sortTable(field);
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize column resize
|
||||
window.app.modules.table.initializeColumnResize();
|
||||
|
||||
// Modal close buttons
|
||||
document.querySelectorAll('.modal-close').forEach(btn => {
|
||||
btn.addEventListener('click', closeModal);
|
||||
});
|
||||
|
||||
// Modal backdrop clicks
|
||||
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
||||
backdrop.addEventListener('click', closeModal);
|
||||
});
|
||||
|
||||
// Drop modal buttons
|
||||
const dropModal = document.getElementById('dropModal');
|
||||
dropModal.querySelector('.modal-cancel').addEventListener('click', closeModal);
|
||||
dropModal.querySelector('.modal-confirm').addEventListener('click', () => window.app.modules.dragDrop.confirmTransmittal());
|
||||
|
||||
// Drag and drop (local mode only — requires write access)
|
||||
if (window.app.sourceMode === 'local') {
|
||||
window.app.modules.dragDrop.setupDragAndDrop();
|
||||
}
|
||||
|
||||
// Multi-select for folders
|
||||
setupFolderMultiSelect();
|
||||
|
||||
// Date group toggle handlers
|
||||
setupDateGroupToggles();
|
||||
|
||||
// Grouping section collapse toggle
|
||||
setupGroupingToggle();
|
||||
|
||||
// Resizable panes
|
||||
setupResizablePanes();
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', handleKeyboardShortcuts);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Handle grouping filter
|
||||
function handleGroupingFilter(e) {
|
||||
window.app.groupingFilter = e.target.value;
|
||||
window.app.modules.app.renderGroupingFolders();
|
||||
// Re-render transmittal folders as they depend on grouping selection
|
||||
window.app.modules.app.renderTransmittalFolders();
|
||||
// Re-filter files based on updated folder selections
|
||||
window.app.modules.filtering.applyFilters();
|
||||
}
|
||||
|
||||
// Handle transmittal filter
|
||||
function handleTransmittalFilter(e) {
|
||||
window.app.transmittalFilter = e.target.value;
|
||||
window.app.modules.app.renderTransmittalFolders();
|
||||
// Re-filter files based on updated folder selections
|
||||
window.app.modules.filtering.applyFilters();
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeModal(e) {
|
||||
const modal = e.target.closest('.modal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
function handleKeyboardShortcuts(e) {
|
||||
// Escape closes modals
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelectorAll('.modal:not(.hidden)').forEach(modal => {
|
||||
modal.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// Ctrl+A selects all visible files
|
||||
if (e.ctrlKey && e.key === 'a' && e.target.tagName !== 'INPUT') {
|
||||
e.preventDefault();
|
||||
toggleSelectAll();
|
||||
}
|
||||
|
||||
// F5 refreshes
|
||||
if (e.key === 'F5') {
|
||||
e.preventDefault();
|
||||
window.app.modules.directory.refreshDirectories();
|
||||
}
|
||||
}
|
||||
|
||||
// Utility: Debounce function
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Multi-select handling for folder lists
|
||||
function setupFolderMultiSelect() {
|
||||
let lastSelectedGroupingIndex = -1;
|
||||
let lastSelectedTransmittalIndex = -1;
|
||||
|
||||
// Handle grouping folders
|
||||
const groupingList = document.getElementById('groupingFoldersList');
|
||||
groupingList.addEventListener('click', (e) => {
|
||||
const result = handleFolderClick(e, window.app.selectedGroupingFolders, lastSelectedGroupingIndex);
|
||||
if (result !== undefined) {
|
||||
lastSelectedGroupingIndex = result;
|
||||
// Turn off "Select All" mode when user manually selects
|
||||
if (window.app.selectAllGroupingFolders) {
|
||||
window.app.selectAllGroupingFolders = false;
|
||||
document.getElementById('selectAllGroupingCheckbox').checked = false;
|
||||
}
|
||||
// Update selection state first
|
||||
window.app.modules.app.updateFolderSelectionState('groupingFoldersList');
|
||||
// Then update transmittal folder list based on new selection
|
||||
window.app.modules.app.renderTransmittalFolders();
|
||||
window.app.modules.filtering.applyFilters(); // Re-filter files
|
||||
// Check presets dirty state
|
||||
if (window.app.modules.presets) {
|
||||
window.app.modules.presets.checkDirty();
|
||||
}
|
||||
// Reset transmittal index since list may have changed
|
||||
lastSelectedTransmittalIndex = -1;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle transmittal folders
|
||||
const transmittalList = document.getElementById('transmittalFoldersList');
|
||||
transmittalList.addEventListener('click', (e) => {
|
||||
const result = handleFolderClick(e, window.app.selectedTransmittalFolders, lastSelectedTransmittalIndex);
|
||||
if (result !== undefined) {
|
||||
lastSelectedTransmittalIndex = result;
|
||||
// Turn off "Select All" mode when user manually selects
|
||||
if (window.app.selectAllTransmittals) {
|
||||
window.app.selectAllTransmittals = false;
|
||||
document.getElementById('selectAllTransmittalsCheckbox').checked = false;
|
||||
}
|
||||
// Update selection state without rebuilding DOM
|
||||
window.app.modules.app.updateFolderSelectionState('transmittalFoldersList');
|
||||
window.app.modules.filtering.applyFilters(); // Update file display
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Ctrl+A for folder lists
|
||||
groupingList.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'a') {
|
||||
e.preventDefault();
|
||||
selectAllVisibleFolders('grouping');
|
||||
}
|
||||
});
|
||||
|
||||
transmittalList.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'a') {
|
||||
e.preventDefault();
|
||||
selectAllVisibleFolders('transmittal');
|
||||
}
|
||||
});
|
||||
|
||||
// Make lists focusable
|
||||
groupingList.setAttribute('tabindex', '0');
|
||||
transmittalList.setAttribute('tabindex', '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle folder click with multi-select support (Shift/Ctrl)
|
||||
* @param {Event} e - Click event
|
||||
* @param {Set} selectedSet - Set of selected folder paths
|
||||
* @param {number} lastIndex - Index of last clicked item
|
||||
* @returns {number|undefined} Current index if valid click, undefined otherwise
|
||||
*/
|
||||
function handleFolderClick(e, selectedSet, lastIndex) {
|
||||
const folderItem = e.target.closest('.folder-item');
|
||||
if (!folderItem) return undefined;
|
||||
|
||||
const path = folderItem.getAttribute('data-path');
|
||||
if (!path) return undefined;
|
||||
|
||||
const container = folderItem.parentElement;
|
||||
const items = Array.from(container.children);
|
||||
const currentIndex = items.indexOf(folderItem);
|
||||
|
||||
if (e.shiftKey && lastIndex !== -1 && lastIndex < items.length) {
|
||||
// Shift+click: select range from last to current
|
||||
e.preventDefault();
|
||||
const start = Math.min(lastIndex, currentIndex);
|
||||
const end = Math.max(lastIndex, currentIndex);
|
||||
|
||||
if (!e.ctrlKey) {
|
||||
selectedSet.clear();
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
const itemPath = items[i]?.getAttribute('data-path');
|
||||
if (itemPath) {
|
||||
selectedSet.add(itemPath);
|
||||
}
|
||||
}
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
// Ctrl+click: toggle individual selection
|
||||
e.preventDefault();
|
||||
if (selectedSet.has(path)) {
|
||||
selectedSet.delete(path);
|
||||
} else {
|
||||
selectedSet.add(path);
|
||||
}
|
||||
} else {
|
||||
// Regular click: clear and select single item
|
||||
selectedSet.clear();
|
||||
selectedSet.add(path);
|
||||
}
|
||||
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle expand/collapse state of a grouping folder
|
||||
* @param {string} path - Folder path to toggle
|
||||
* @param {boolean} recursive - If true, also toggle all descendants
|
||||
*/
|
||||
function toggleGroupingFolder(path, recursive) {
|
||||
const isCurrentlyCollapsed = window.app.collapsedGroupingFolders.has(path);
|
||||
|
||||
if (recursive) {
|
||||
// Get all descendant folder paths
|
||||
const descendants = window.app.groupingFolders
|
||||
.filter(f => f.path.startsWith(path + '/'))
|
||||
.map(f => f.path);
|
||||
|
||||
if (isCurrentlyCollapsed) {
|
||||
// Expand this folder and all descendants
|
||||
window.app.collapsedGroupingFolders.delete(path);
|
||||
descendants.forEach(p => window.app.collapsedGroupingFolders.delete(p));
|
||||
} else {
|
||||
// Collapse this folder and all descendants
|
||||
window.app.collapsedGroupingFolders.add(path);
|
||||
descendants.forEach(p => window.app.collapsedGroupingFolders.add(p));
|
||||
}
|
||||
} else {
|
||||
// Just toggle this folder
|
||||
if (isCurrentlyCollapsed) {
|
||||
window.app.collapsedGroupingFolders.delete(path);
|
||||
} else {
|
||||
window.app.collapsedGroupingFolders.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
window.app.modules.app.renderGroupingFolders();
|
||||
}
|
||||
|
||||
// Select all visible folders
|
||||
function selectAllVisibleFolders(folderType) {
|
||||
const container = folderType === 'grouping' ?
|
||||
document.getElementById('groupingFoldersList') :
|
||||
document.getElementById('transmittalFoldersList');
|
||||
|
||||
const selectedSet = folderType === 'grouping' ?
|
||||
window.app.selectedGroupingFolders :
|
||||
window.app.selectedTransmittalFolders;
|
||||
|
||||
selectedSet.clear();
|
||||
|
||||
const items = container.querySelectorAll('.folder-item');
|
||||
items.forEach(item => {
|
||||
const path = item.getAttribute('data-path');
|
||||
if (path) {
|
||||
selectedSet.add(path);
|
||||
}
|
||||
});
|
||||
|
||||
if (folderType === 'grouping') {
|
||||
// Update UI to reflect grouping changes
|
||||
window.app.modules.app.updateUI();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
} else {
|
||||
// For transmittal folders, just update selection state
|
||||
window.app.modules.app.updateFolderSelectionState('transmittalFoldersList');
|
||||
window.app.modules.filtering.applyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
// Setup date group toggle handlers
|
||||
function setupDateGroupToggles() {
|
||||
// Toggle all dates button
|
||||
const toggleAllBtn = document.getElementById('toggleAllDatesBtn');
|
||||
if (toggleAllBtn) {
|
||||
toggleAllBtn.addEventListener('click', toggleAllDateGroups);
|
||||
}
|
||||
|
||||
// Individual date group headers (using event delegation)
|
||||
const transmittalList = document.getElementById('transmittalFoldersList');
|
||||
transmittalList.addEventListener('click', (e) => {
|
||||
const header = e.target.closest('.date-group-header');
|
||||
if (header) {
|
||||
const date = header.getAttribute('data-date');
|
||||
if (date) {
|
||||
toggleDateGroup(date);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle a single date group
|
||||
function toggleDateGroup(date) {
|
||||
if (window.app.collapsedDateGroups.has(date)) {
|
||||
window.app.collapsedDateGroups.delete(date);
|
||||
} else {
|
||||
window.app.collapsedDateGroups.add(date);
|
||||
}
|
||||
window.app.modules.app.renderTransmittalFolders();
|
||||
updateToggleAllIcon();
|
||||
}
|
||||
|
||||
// Toggle all date groups
|
||||
function toggleAllDateGroups() {
|
||||
const headers = document.querySelectorAll('.date-group-header');
|
||||
const allDates = Array.from(headers).map(h => h.getAttribute('data-date')).filter(Boolean);
|
||||
|
||||
// If all are collapsed, expand all. Otherwise, collapse all.
|
||||
const allCollapsed = allDates.length > 0 && allDates.every(date => window.app.collapsedDateGroups.has(date));
|
||||
|
||||
if (allCollapsed) {
|
||||
// Expand all
|
||||
window.app.collapsedDateGroups.clear();
|
||||
} else {
|
||||
// Collapse all
|
||||
allDates.forEach(date => window.app.collapsedDateGroups.add(date));
|
||||
}
|
||||
|
||||
window.app.modules.app.renderTransmittalFolders();
|
||||
updateToggleAllIcon();
|
||||
}
|
||||
|
||||
// Update the toggle all icon based on current state
|
||||
function updateToggleAllIcon() {
|
||||
const icon = document.getElementById('toggleAllDatesIcon');
|
||||
if (!icon) return;
|
||||
|
||||
const headers = document.querySelectorAll('.date-group-header');
|
||||
const allDates = Array.from(headers).map(h => h.getAttribute('data-date')).filter(Boolean);
|
||||
const allCollapsed = allDates.length > 0 && allDates.every(date => window.app.collapsedDateGroups.has(date));
|
||||
|
||||
icon.textContent = allCollapsed ? '▶' : '▼';
|
||||
}
|
||||
|
||||
// Setup grouping section collapse toggle
|
||||
function setupGroupingToggle() {
|
||||
const toggleBtn = document.getElementById('toggleGroupingBtn');
|
||||
const groupingSection = document.getElementById('groupingSection');
|
||||
const icon = document.getElementById('toggleGroupingIcon');
|
||||
|
||||
if (toggleBtn && groupingSection && icon) {
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
groupingSection.classList.toggle('collapsed');
|
||||
icon.textContent = groupingSection.classList.contains('collapsed') ? '▶' : '▼';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Setup resizable panes
|
||||
function setupResizablePanes() {
|
||||
// Resize nav sections (vertical divider between grouping and transmittal)
|
||||
const navSectionsHandle = document.querySelector('[data-resize="nav-sections"]');
|
||||
if (navSectionsHandle) {
|
||||
let isResizing = false;
|
||||
let startY = 0;
|
||||
let startHeight = 0;
|
||||
let groupingSection = null;
|
||||
|
||||
navSectionsHandle.addEventListener('mousedown', (e) => {
|
||||
isResizing = true;
|
||||
startY = e.clientY;
|
||||
groupingSection = document.getElementById('groupingSection');
|
||||
startHeight = groupingSection.offsetHeight;
|
||||
navSectionsHandle.classList.add('resizing');
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const deltaY = e.clientY - startY;
|
||||
const newHeight = startHeight + deltaY;
|
||||
|
||||
// Set min/max heights
|
||||
if (newHeight >= 100 && newHeight <= window.innerHeight - 250) {
|
||||
groupingSection.style.flex = 'none';
|
||||
groupingSection.style.height = newHeight + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isResizing) {
|
||||
isResizing = false;
|
||||
navSectionsHandle.classList.remove('resizing');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Resize nav pane (horizontal divider between nav and content)
|
||||
const navPaneHandle = document.querySelector('[data-resize="nav-pane"]');
|
||||
if (navPaneHandle) {
|
||||
let isResizing = false;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
let navPane = null;
|
||||
|
||||
navPaneHandle.addEventListener('mousedown', (e) => {
|
||||
isResizing = true;
|
||||
startX = e.clientX;
|
||||
navPane = document.getElementById('navigationPane');
|
||||
startWidth = navPane.offsetWidth;
|
||||
navPaneHandle.classList.add('resizing');
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const deltaX = e.clientX - startX;
|
||||
const newWidth = startWidth + deltaX;
|
||||
|
||||
// Set min/max widths
|
||||
if (newWidth >= 200 && newWidth <= window.innerWidth - 400) {
|
||||
navPane.style.width = newWidth + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isResizing) {
|
||||
isResizing = false;
|
||||
navPaneHandle.classList.remove('resizing');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.app.modules.events = {
|
||||
setupEventListeners,
|
||||
handleFolderClick,
|
||||
toggleGroupingFolder,
|
||||
selectAllVisibleFolders,
|
||||
setupDateGroupToggles,
|
||||
toggleDateGroup,
|
||||
toggleAllDateGroups,
|
||||
updateToggleAllIcon,
|
||||
setupGroupingToggle,
|
||||
setupResizablePanes
|
||||
};
|
||||
|
||||
})();
|
||||
272
archive/js/export.js
Normal file
272
archive/js/export.js
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
// Export functionality
|
||||
|
||||
// Escape a single value for RFC-4180 CSV
|
||||
function csvCell(value) {
|
||||
const str = String(value == null ? '' : value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
// Convert an array of row arrays to a CSV string
|
||||
function rowsToCSV(rows) {
|
||||
return rows.map(row => row.map(csvCell).join(',')).join('\n');
|
||||
}
|
||||
|
||||
// Export selected files to CSV
|
||||
function exportCSV() {
|
||||
if (window.app.selectedFiles.size === 0) {
|
||||
alert('No files selected for export.');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ['Tracking Number', 'Title', 'Revision', 'Status', 'Extension', 'Size', 'Size (bytes)', 'Path', 'Modified'];
|
||||
const rows = [headers];
|
||||
|
||||
// Add data rows for selected files only
|
||||
window.app.files.forEach(file => {
|
||||
if (!window.app.selectedFiles.has(file.id)) return;
|
||||
|
||||
rows.push([
|
||||
file.trackingNumber || '',
|
||||
file.title || '',
|
||||
file.revision || '',
|
||||
file.status || '',
|
||||
file.extension || '',
|
||||
formatFileSize(file.size),
|
||||
file.size != null ? file.size : '',
|
||||
file.path,
|
||||
file.modified ? new Date(file.modified).toLocaleString() : '—'
|
||||
]);
|
||||
});
|
||||
|
||||
downloadFile(rowsToCSV(rows), 'archive-export.csv', 'text/csv');
|
||||
}
|
||||
|
||||
// Download selected files as ZIP
|
||||
async function downloadSelected() {
|
||||
if (window.app.selectedFiles.size === 0) {
|
||||
alert('No files selected for download.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if JSZip is loaded
|
||||
if (typeof JSZip === 'undefined') {
|
||||
// Dynamically load JSZip
|
||||
await loadJSZip();
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
const selectedFiles = [];
|
||||
|
||||
// Get selected file objects
|
||||
window.app.files.forEach(file => {
|
||||
if (window.app.selectedFiles.has(file.id)) {
|
||||
selectedFiles.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
// Show progress
|
||||
showProgress('Preparing ZIP file...', 0, selectedFiles.length);
|
||||
|
||||
try {
|
||||
// Add files to ZIP
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const file = selectedFiles[i];
|
||||
showProgress(`Adding ${file.name}...`, i + 1, selectedFiles.length);
|
||||
|
||||
try {
|
||||
let arrayBuffer;
|
||||
if (file.handle) {
|
||||
// Local mode: read via File System Access API
|
||||
const fileData = await file.handle.getFile();
|
||||
arrayBuffer = await fileData.arrayBuffer();
|
||||
} else if (file.url) {
|
||||
// HTTP mode: fetch from server
|
||||
const resp = await fetch(file.url);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
arrayBuffer = await resp.arrayBuffer();
|
||||
} else {
|
||||
throw new Error('No file handle or URL available');
|
||||
}
|
||||
|
||||
// Create folder structure in ZIP
|
||||
const relativePath = file.path.substring(file.path.indexOf('/') + 1); // Remove root directory
|
||||
zip.file(relativePath, arrayBuffer);
|
||||
} catch (err) {
|
||||
console.error(`Error adding file ${file.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
showProgress('Generating ZIP...', selectedFiles.length, selectedFiles.length);
|
||||
|
||||
// Generate ZIP
|
||||
const blob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 }
|
||||
});
|
||||
|
||||
// Download
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
||||
downloadBlob(blob, `archive-${timestamp}.zip`);
|
||||
|
||||
hideProgress();
|
||||
|
||||
} catch (err) {
|
||||
hideProgress();
|
||||
console.error('Error creating ZIP:', err);
|
||||
alert('Error creating ZIP file: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Load JSZip library dynamically
|
||||
function loadJSZip() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// Show progress indicator
|
||||
function showProgress(message, current, total) {
|
||||
let progressDiv = document.getElementById('progressIndicator');
|
||||
|
||||
if (!progressDiv) {
|
||||
progressDiv = document.createElement('div');
|
||||
progressDiv.id = 'progressIndicator';
|
||||
progressDiv.className = 'progress-indicator';
|
||||
document.body.appendChild(progressDiv);
|
||||
}
|
||||
|
||||
const percentage = Math.round((current / total) * 100);
|
||||
|
||||
progressDiv.innerHTML =
|
||||
'<div class="progress-indicator__message">' + window.app.modules.app.escapeHtml(message) + '</div>' +
|
||||
'<div class="progress-indicator__track">' +
|
||||
'<div class="progress-indicator__fill" style="width:' + percentage + '%"></div>' +
|
||||
'</div>' +
|
||||
'<div class="progress-indicator__label">' + current + ' / ' + total + '</div>';
|
||||
}
|
||||
|
||||
// Hide progress indicator
|
||||
function hideProgress() {
|
||||
const progressDiv = document.getElementById('progressIndicator');
|
||||
if (progressDiv) {
|
||||
progressDiv.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Download file utility
|
||||
function downloadFile(content, filename, mimeType) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
downloadBlob(blob, filename);
|
||||
}
|
||||
|
||||
// Download blob utility
|
||||
function downloadBlob(blob, filename) {
|
||||
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);
|
||||
}
|
||||
|
||||
// Format file size
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Export to HTML report
|
||||
function exportHTMLReport() {
|
||||
// Group files by tracking number
|
||||
const grouped = window.app.modules.parser.groupFilesByTrackingNumber(window.app.filteredFiles);
|
||||
const sorted = window.app.modules.parser.sortGroupedFiles(grouped);
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Archive Report - ${new Date().toLocaleDateString()}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
h1 { color: #333; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f2f2f2; font-weight: bold; }
|
||||
tr:nth-child(even) { background-color: #f9f9f9; }
|
||||
.revision { font-family: monospace; }
|
||||
.status { color: #666; font-size: 0.9em; }
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
h1 { font-size: 18pt; }
|
||||
table { font-size: 10pt; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Archive Report</h1>
|
||||
<p>Generated: ${new Date().toLocaleString()}</p>
|
||||
<p>Total Files: ${window.app.filteredFiles.length}</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tracking Number</th>
|
||||
<th>Title</th>
|
||||
<th>Revisions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sorted.map(group => `
|
||||
<tr>
|
||||
<td>${window.app.modules.app.escapeHtml(group.trackingNumber)}</td>
|
||||
<td>${window.app.modules.app.escapeHtml(group.title)}</td>
|
||||
<td>
|
||||
${group.sortedRevisions.map(rev => `
|
||||
<div>
|
||||
<span class="revision">${window.app.modules.app.escapeHtml(rev.revision)}</span>
|
||||
<span class="status">(${window.app.modules.app.escapeHtml(rev.status)})</span>
|
||||
${rev.files.map(f => f.extension.toUpperCase()).join(', ')}
|
||||
</div>
|
||||
`).join('')}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
downloadFile(html, 'archive-report.html', 'text/html');
|
||||
}
|
||||
|
||||
window.app.modules.export = {
|
||||
csvCell,
|
||||
rowsToCSV,
|
||||
exportCSV,
|
||||
downloadSelected,
|
||||
loadJSZip,
|
||||
showProgress,
|
||||
hideProgress,
|
||||
downloadFile,
|
||||
downloadBlob,
|
||||
formatFileSize,
|
||||
exportHTMLReport
|
||||
};
|
||||
|
||||
})();
|
||||
141
archive/js/filtering.js
Normal file
141
archive/js/filtering.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// Filtering functionality
|
||||
|
||||
// Apply all filters
|
||||
function applyFilters() {
|
||||
// Start with files from selected transmittal folders AND selected grouping folders
|
||||
let filtered = window.app.files.filter(file => {
|
||||
// Must have at least one grouping folder selected (if grouping folders exist)
|
||||
if (window.app.groupingFolders.length > 0 && window.app.selectedGroupingFolders.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have at least one transmittal folder selected
|
||||
if (window.app.selectedTransmittalFolders.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// File must be in a selected transmittal folder
|
||||
if (!window.app.selectedTransmittalFolders.has(file.folderPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Outstanding files: actualPath must be under a selected grouping folder that is
|
||||
// itself visible (not hidden by folder type toggles).
|
||||
if (file.folderPath === '__outstanding__') {
|
||||
if (!window.app.modules.app.outstandingFileIsVisible(file)) return false;
|
||||
}
|
||||
|
||||
// If grouping folders exist and are selected, the file's transmittal folder must be within one.
|
||||
// Outstanding files are exempt — their grouping scope is enforced by the actualPath check above.
|
||||
if (file.folderPath !== '__outstanding__' && window.app.groupingFolders.length > 0 && window.app.selectedGroupingFolders.size > 0) {
|
||||
const inSelectedGrouping = Array.from(window.app.selectedGroupingFolders).some(groupingPath =>
|
||||
file.folderPath.startsWith(groupingPath + '/')
|
||||
);
|
||||
if (!inSelectedGrouping) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Apply column filters
|
||||
filtered = applyColumnFilters(filtered);
|
||||
|
||||
// Apply modifier filter
|
||||
if (window.app.selectedModifiers.size < window.app.availableModifiers.size) {
|
||||
filtered = filtered.filter(file => window.app.modules.app.filePassesModifierFilter(file));
|
||||
}
|
||||
|
||||
updateResetFiltersBtn();
|
||||
|
||||
// Apply selected-only filter
|
||||
if (window.app.showSelectedOnly) {
|
||||
filtered = filtered.filter(file => window.app.selectedFiles.has(file.id));
|
||||
}
|
||||
|
||||
window.app.filteredFiles = filtered;
|
||||
window.app.modules.table.updateFileTable();
|
||||
window.app.modules.app.updateStatusBar();
|
||||
window.app.modules.table.updateSelectAllVisibleCheckbox();
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Apply column filters using stored ASTs
|
||||
function applyColumnFilters(files) {
|
||||
const asts = window.app.columnFilterASTs;
|
||||
|
||||
if (asts.trackingNumber && asts.trackingNumber.length > 0) {
|
||||
files = files.filter(file =>
|
||||
zddc.filter.matches(file.trackingNumber || '', asts.trackingNumber)
|
||||
);
|
||||
}
|
||||
|
||||
if (asts.title && asts.title.length > 0) {
|
||||
files = files.filter(file =>
|
||||
zddc.filter.matches(file.title || '', asts.title)
|
||||
);
|
||||
}
|
||||
|
||||
if (asts.revisions && asts.revisions.length > 0) {
|
||||
files = files.filter(file => {
|
||||
const revisionText = [
|
||||
file.revision,
|
||||
file.status,
|
||||
file.extension
|
||||
].join(' ');
|
||||
return zddc.filter.matches(revisionText, asts.revisions);
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// Clear all filters
|
||||
function clearFilters() {
|
||||
window.app.columnFilters = {
|
||||
trackingNumber: '',
|
||||
title: '',
|
||||
revisions: ''
|
||||
};
|
||||
window.app.columnFilterASTs = {
|
||||
trackingNumber: null,
|
||||
title: null,
|
||||
revisions: null
|
||||
};
|
||||
window.app.groupingFilter = '';
|
||||
window.app.transmittalFilter = '';
|
||||
|
||||
// Clear UI inputs
|
||||
const groupingFilterEl = document.getElementById('groupingFilter');
|
||||
groupingFilterEl.value = '';
|
||||
groupingFilterEl.classList.remove('filter-active');
|
||||
const transmittalFilterEl = document.getElementById('transmittalFilter');
|
||||
transmittalFilterEl.value = '';
|
||||
transmittalFilterEl.classList.remove('filter-active');
|
||||
document.querySelectorAll('.column-filter').forEach(input => {
|
||||
input.value = '';
|
||||
input.classList.remove('filter-active');
|
||||
});
|
||||
|
||||
window.app.modules.app.toggleAllModifiers(true);
|
||||
updateResetFiltersBtn();
|
||||
applyFilters();
|
||||
window.app.modules.urlState.push();
|
||||
}
|
||||
|
||||
// Update reset filters button visibility
|
||||
function updateResetFiltersBtn() {
|
||||
// Button is always visible — no show/hide logic needed
|
||||
}
|
||||
|
||||
// Register filtering module
|
||||
window.app.modules.filtering = {
|
||||
applyFilters,
|
||||
applyColumnFilters,
|
||||
clearFilters,
|
||||
updateResetFiltersBtn
|
||||
};
|
||||
194
archive/js/hash.js
Normal file
194
archive/js/hash.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
// SHA-256 hashing and cache management
|
||||
|
||||
const HASH_CACHE_FILENAME = '.hashes.json';
|
||||
|
||||
// Calculate SHA-256 hash for a file (delegates to shared/hash.js)
|
||||
async function calculateFileHash(file) {
|
||||
return zddc.crypto.sha256File(file);
|
||||
}
|
||||
|
||||
// Load hash cache for a directory
|
||||
async function loadHashCache(dirHandle) {
|
||||
try {
|
||||
const cacheHandle = await dirHandle.getFileHandle(HASH_CACHE_FILENAME);
|
||||
const cacheFile = await cacheHandle.getFile();
|
||||
const cacheText = await cacheFile.text();
|
||||
return JSON.parse(cacheText);
|
||||
} catch (err) {
|
||||
// Cache doesn't exist or can't be read
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Save hash cache for a directory
|
||||
async function saveHashCache(dirHandle, cache) {
|
||||
try {
|
||||
const cacheHandle = await dirHandle.getFileHandle(HASH_CACHE_FILENAME, { create: true });
|
||||
const writable = await cacheHandle.createWritable();
|
||||
await writable.write(JSON.stringify(cache, null, 2));
|
||||
await writable.close();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('Unable to save hash cache:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hash files in a directory with caching
|
||||
async function hashDirectoryFiles(dirHandle, files) {
|
||||
const cache = await loadHashCache(dirHandle);
|
||||
const updatedCache = {};
|
||||
const results = {};
|
||||
|
||||
for (const fileInfo of files) {
|
||||
try {
|
||||
const file = await fileInfo.handle.getFile();
|
||||
const cacheKey = file.name;
|
||||
const lastModified = file.lastModified;
|
||||
|
||||
// Check if we have a cached hash
|
||||
if (cache[cacheKey] && cache[cacheKey].lastModified === lastModified) {
|
||||
// Use cached hash
|
||||
results[fileInfo.id] = cache[cacheKey].hash;
|
||||
updatedCache[cacheKey] = cache[cacheKey];
|
||||
} else {
|
||||
// Calculate new hash
|
||||
const hash = await calculateFileHash(file);
|
||||
results[fileInfo.id] = hash;
|
||||
updatedCache[cacheKey] = {
|
||||
hash: hash,
|
||||
lastModified: lastModified,
|
||||
size: file.size
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error hashing file ${fileInfo.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to save updated cache
|
||||
await saveHashCache(dirHandle, updatedCache);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Add hash information to files
|
||||
async function addHashesToFiles() {
|
||||
if (!window.app.files.length) return;
|
||||
|
||||
// Hash operations require local file handles — not available in HTTP mode
|
||||
if (window.app.sourceMode === 'http') {
|
||||
console.log('Hash operations not available in HTTP mode.');
|
||||
return;
|
||||
}
|
||||
|
||||
window.app.modules.export.showProgress('Calculating file hashes...', 0, window.app.files.length);
|
||||
|
||||
try {
|
||||
// Group files by directory
|
||||
const filesByDir = {};
|
||||
window.app.files.forEach(file => {
|
||||
const dirPath = file.folderPath;
|
||||
if (!filesByDir[dirPath]) {
|
||||
filesByDir[dirPath] = {
|
||||
handle: null,
|
||||
files: []
|
||||
};
|
||||
}
|
||||
filesByDir[dirPath].files.push(file);
|
||||
});
|
||||
|
||||
// Find directory handles
|
||||
for (const dirPath in filesByDir) {
|
||||
const folder = window.app.transmittalFolders.find(f => f.path === dirPath);
|
||||
if (folder) {
|
||||
filesByDir[dirPath].handle = folder.handle;
|
||||
}
|
||||
}
|
||||
|
||||
// Hash files in each directory
|
||||
let processed = 0;
|
||||
for (const dirPath in filesByDir) {
|
||||
const dirInfo = filesByDir[dirPath];
|
||||
if (dirInfo.handle) {
|
||||
const hashes = await hashDirectoryFiles(dirInfo.handle, dirInfo.files);
|
||||
|
||||
// Update file objects with hashes
|
||||
dirInfo.files.forEach(file => {
|
||||
if (hashes[file.id]) {
|
||||
file.hash = hashes[file.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
processed += dirInfo.files.length;
|
||||
window.app.modules.export.showProgress('Calculating file hashes...', processed, window.app.files.length);
|
||||
}
|
||||
|
||||
window.app.modules.export.hideProgress();
|
||||
|
||||
} catch (err) {
|
||||
window.app.modules.export.hideProgress();
|
||||
console.error('Error calculating hashes:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify file integrity
|
||||
async function verifyFileIntegrity(fileId) {
|
||||
const file = window.app.files.find(f => f.id === fileId);
|
||||
if (!file || !file.hash) {
|
||||
alert('No hash available for this file.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.handle) {
|
||||
alert('File integrity verification is not available in HTTP mode.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileData = await file.handle.getFile();
|
||||
const currentHash = await calculateFileHash(fileData);
|
||||
|
||||
if (currentHash === file.hash) {
|
||||
alert('File integrity verified. Hash matches.');
|
||||
} else {
|
||||
alert('WARNING: File has been modified! Hash does not match.');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error verifying file: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Export hash report
|
||||
function exportHashReport() {
|
||||
const headers = ['File Path', 'SHA-256 Hash', 'Size', 'Last Modified'];
|
||||
const rows = [headers];
|
||||
|
||||
window.app.filteredFiles.forEach(file => {
|
||||
if (file.hash) {
|
||||
rows.push([
|
||||
file.path,
|
||||
file.hash,
|
||||
window.app.modules.export.formatFileSize(file.size),
|
||||
new Date(file.modified).toISOString()
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
window.app.modules.export.downloadFile(window.app.modules.export.rowsToCSV(rows), 'file-hashes.csv', 'text/csv');
|
||||
}
|
||||
|
||||
window.app.modules.hash = {
|
||||
calculateFileHash,
|
||||
loadHashCache,
|
||||
saveHashCache,
|
||||
hashDirectoryFiles,
|
||||
addHashesToFiles,
|
||||
verifyFileIntegrity,
|
||||
exportHashReport
|
||||
};
|
||||
|
||||
})();
|
||||
41
archive/js/init.js
Normal file
41
archive/js/init.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* ZDDC Archive - Initialization
|
||||
* Sets up window.app and window.app.modules before other modules run.
|
||||
* Must be the first JS file in the build.
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
window.app = {
|
||||
sourceMode: null,
|
||||
directories: [],
|
||||
groupingFolders: [],
|
||||
transmittalFolders: [],
|
||||
files: [],
|
||||
filteredFiles: [],
|
||||
selectedFiles: new Set(),
|
||||
isScanning: false,
|
||||
scanProgress: '',
|
||||
|
||||
columnFilters: { trackingNumber: '', title: '', revisions: '' },
|
||||
columnFilterASTs: { trackingNumber: null, title: null, revisions: null },
|
||||
groupingFilter: '',
|
||||
transmittalFilter: '',
|
||||
enabledFolderTypes: new Set(['issued', 'received']),
|
||||
sortField: 'trackingNumber',
|
||||
sortDirection: 'asc',
|
||||
selectedGroupingFolders: new Set(),
|
||||
selectedTransmittalFolders: new Set(),
|
||||
collapsedDateGroups: new Set(),
|
||||
collapsedGroupingFolders: new Set(),
|
||||
selectAllGroupingFolders: true,
|
||||
selectAllTransmittals: true,
|
||||
availableModifiers: new Set(),
|
||||
selectedModifiers: new Set(),
|
||||
showSelectedOnly: false,
|
||||
projectFilter: new Set(),
|
||||
FOLDER_TYPE_NAMES: ['issued', 'received', 'mdl', 'incoming'],
|
||||
modules: {}
|
||||
};
|
||||
|
||||
})();
|
||||
65
archive/js/parser.js
Normal file
65
archive/js/parser.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Archive grouping/sorting helpers — ZDDC parsing comes from window.zddc directly.
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function isTransmittalFolder(name) {
|
||||
var parsed = zddc.parseFolder(name);
|
||||
return !!(parsed && parsed.valid);
|
||||
}
|
||||
|
||||
function groupFilesByTrackingNumber(files) {
|
||||
const groups = {};
|
||||
files.forEach(file => {
|
||||
if (!file.trackingNumber) return;
|
||||
if (!groups[file.trackingNumber]) {
|
||||
groups[file.trackingNumber] = { trackingNumber: file.trackingNumber, title: file.title, revisions: {} };
|
||||
}
|
||||
if (file.title.length > groups[file.trackingNumber].title.length) {
|
||||
groups[file.trackingNumber].title = file.title;
|
||||
}
|
||||
const revKey = `${file.revision}_${file.status}`;
|
||||
if (!groups[file.trackingNumber].revisions[revKey]) {
|
||||
groups[file.trackingNumber].revisions[revKey] = {
|
||||
revision: file.revision, status: file.status, title: file.title,
|
||||
hasModifier: file.revision.includes('+'), files: []
|
||||
};
|
||||
}
|
||||
if (file.title.length > groups[file.trackingNumber].revisions[revKey].title.length) {
|
||||
groups[file.trackingNumber].revisions[revKey].title = file.title;
|
||||
}
|
||||
groups[file.trackingNumber].revisions[revKey].files.push(file);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function sortGroupedFiles(groups) {
|
||||
const field = window.app.sortField || 'trackingNumber';
|
||||
const direction = window.app.sortDirection === 'desc' ? -1 : 1;
|
||||
const sorted = Object.values(groups).sort((a, b) => {
|
||||
let comparison = 0;
|
||||
if (field === 'trackingNumber') comparison = a.trackingNumber.localeCompare(b.trackingNumber);
|
||||
else if (field === 'title') comparison = a.title.localeCompare(b.title);
|
||||
else if (field === 'revisions') {
|
||||
const aRevs = Object.keys(a.revisions), bRevs = Object.keys(b.revisions);
|
||||
comparison = zddc.compareRevisions(
|
||||
aRevs.length > 0 ? aRevs[aRevs.length - 1] : '',
|
||||
bRevs.length > 0 ? bRevs[bRevs.length - 1] : ''
|
||||
);
|
||||
}
|
||||
return comparison * direction;
|
||||
});
|
||||
sorted.forEach(group => {
|
||||
const revisions = Object.values(group.revisions);
|
||||
revisions.sort((a, b) => zddc.compareRevisions(b.revision, a.revision));
|
||||
group.sortedRevisions = revisions;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
window.app.modules.parser = {
|
||||
isTransmittalFolder,
|
||||
groupFilesByTrackingNumber,
|
||||
sortGroupedFiles,
|
||||
};
|
||||
|
||||
})();
|
||||
437
archive/js/presets.js
Normal file
437
archive/js/presets.js
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
// Party presets for archive browser — IIFE module
|
||||
|
||||
// State (module scope, NOT on window.app)
|
||||
let presets = [];
|
||||
let activePresetName = null;
|
||||
let isOpen = false;
|
||||
let isNamingMode = false;
|
||||
|
||||
// Get localStorage key based on source mode and directory
|
||||
function getStorageKey() {
|
||||
if (window.app.sourceMode === 'http' && window.app.directories.length > 0) {
|
||||
var u = window.app.directories[0].url || '';
|
||||
return 'zddc-presets:http:' + u;
|
||||
} else if (window.app.sourceMode === 'local' && window.app.directories.length > 0) {
|
||||
return 'zddc-presets:local:' + window.app.directories[0].name;
|
||||
}
|
||||
return 'zddc-presets:default';
|
||||
}
|
||||
|
||||
// Load presets from localStorage
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
var stored = localStorage.getItem(getStorageKey());
|
||||
if (stored) {
|
||||
var parsed = JSON.parse(stored);
|
||||
if (parsed && Array.isArray(parsed.presets)) {
|
||||
presets = parsed.presets;
|
||||
} else {
|
||||
presets = [];
|
||||
}
|
||||
} else {
|
||||
presets = [];
|
||||
}
|
||||
} catch (e) {
|
||||
presets = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Save presets to localStorage
|
||||
function saveToStorage() {
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(), JSON.stringify({ presets: presets }));
|
||||
} catch (e) {
|
||||
// Silently fail on storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// Load a preset by name
|
||||
function loadPreset(name) {
|
||||
var preset = presets.find(p => p.name === name);
|
||||
if (!preset) return;
|
||||
|
||||
// Filter paths to only include folders that exist in groupingFolders
|
||||
var validPaths = preset.paths.filter(p =>
|
||||
window.app.groupingFolders.some(f => f.path === p)
|
||||
);
|
||||
|
||||
window.app.selectedGroupingFolders = new Set(validPaths);
|
||||
window.app.selectAllGroupingFolders = false;
|
||||
|
||||
var checkbox = document.getElementById('selectAllGroupingCheckbox');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
|
||||
activePresetName = name;
|
||||
|
||||
// Trigger UI updates
|
||||
window.app.modules.app.updateFolderSelectionState('groupingFoldersList');
|
||||
window.app.modules.app.renderTransmittalFolders();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
|
||||
renderButton();
|
||||
renderDropdown();
|
||||
}
|
||||
|
||||
// Save current selection as a preset
|
||||
function savePreset(name) {
|
||||
// Build paths array from current selection
|
||||
var paths = Array.from(window.app.selectedGroupingFolders);
|
||||
|
||||
// Upsert preset
|
||||
var existingIndex = presets.findIndex(p => p.name === name);
|
||||
if (existingIndex >= 0) {
|
||||
presets[existingIndex] = { name: name, paths: paths };
|
||||
} else {
|
||||
presets.push({ name: name, paths: paths });
|
||||
}
|
||||
|
||||
saveToStorage();
|
||||
activePresetName = name;
|
||||
isNamingMode = false;
|
||||
|
||||
renderButton();
|
||||
renderDropdown();
|
||||
}
|
||||
|
||||
// Delete a preset by name
|
||||
function deletePreset(name) {
|
||||
presets = presets.filter(p => p.name !== name);
|
||||
if (activePresetName === name) {
|
||||
activePresetName = null;
|
||||
}
|
||||
saveToStorage();
|
||||
renderButton();
|
||||
renderDropdown();
|
||||
}
|
||||
|
||||
// Check if current selection differs from active preset
|
||||
function checkDirty() {
|
||||
if (activePresetName === null) return;
|
||||
|
||||
var preset = presets.find(p => p.name === activePresetName);
|
||||
if (!preset) return;
|
||||
|
||||
var currentPaths = new Set(window.app.selectedGroupingFolders);
|
||||
var presetPaths = new Set(preset.paths || []);
|
||||
|
||||
// Compare sets
|
||||
var dirty = currentPaths.size !== presetPaths.size ||
|
||||
!Array.from(currentPaths).every(p => presetPaths.has(p));
|
||||
|
||||
if (dirty) {
|
||||
renderButton();
|
||||
}
|
||||
}
|
||||
|
||||
// Get minimum depth of grouping folders (for top-level Only)
|
||||
function getMinDepth() {
|
||||
if (window.app.groupingFolders.length === 0) return 1;
|
||||
return Math.min.apply(null, window.app.groupingFolders.map(f => f.path.split('/').length));
|
||||
}
|
||||
|
||||
// Render the preset button label
|
||||
function renderButton() {
|
||||
var btn = document.getElementById('presetBtn');
|
||||
if (!btn) return;
|
||||
|
||||
if (activePresetName !== null) {
|
||||
// Check if dirty
|
||||
var preset = presets.find(p => p.name === activePresetName);
|
||||
var dirty = false;
|
||||
if (preset) {
|
||||
var currentPaths = new Set(window.app.selectedGroupingFolders);
|
||||
var presetPaths = new Set(preset.paths || []);
|
||||
dirty = currentPaths.size !== presetPaths.size ||
|
||||
!Array.from(currentPaths).every(p => presetPaths.has(p));
|
||||
}
|
||||
btn.textContent = '▾ ' + activePresetName + (dirty ? '*' : '');
|
||||
} else {
|
||||
btn.textContent = '▾ Presets';
|
||||
}
|
||||
}
|
||||
|
||||
// Escape HTML for safe insertion
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Render the dropdown panel
|
||||
function renderDropdown() {
|
||||
var dropdown = document.getElementById('presetDropdown');
|
||||
if (!dropdown) return;
|
||||
|
||||
var minDepth = getMinDepth();
|
||||
|
||||
// Build presets list HTML
|
||||
var presetsHtml = '';
|
||||
if (presets.length === 0) {
|
||||
presetsHtml = '<div class="preset-no-presets"><i>No saved presets</i></div>';
|
||||
} else {
|
||||
presetsHtml = presets.map(preset => {
|
||||
var escapedName = escapeHtml(preset.name);
|
||||
return (
|
||||
'<div class="preset-item" data-name="' + escapedName + '">' +
|
||||
'<span>' + escapedName + '</span>' +
|
||||
'<button class="preset-delete" data-name="' + escapedName + '">×</button>' +
|
||||
'</div>'
|
||||
);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Build project checkboxes HTML
|
||||
var projectsHtml = '';
|
||||
window.app.groupingFolders.forEach(folder => {
|
||||
// Only include top-level folders (minDepth)
|
||||
var pathParts = folder.path.split('/');
|
||||
if (pathParts.length !== minDepth) return;
|
||||
|
||||
var isSelected = window.app.selectedGroupingFolders.has(folder.path);
|
||||
var escapedPath = escapeHtml(folder.path);
|
||||
var escapedName = escapeHtml(folder.name);
|
||||
|
||||
projectsHtml += (
|
||||
'<div class="preset-project-item">' +
|
||||
'<label class="preset-project-label">' +
|
||||
'<input type="checkbox" class="preset-checkbox" data-path="' + escapedPath + '"' +
|
||||
(isSelected ? ' checked' : '') + '>' +
|
||||
' ' + escapedName +
|
||||
'</label>' +
|
||||
'</div>'
|
||||
);
|
||||
});
|
||||
|
||||
// Footer HTML
|
||||
var footerHtml = '';
|
||||
if (activePresetName !== null) {
|
||||
// Check if dirty
|
||||
var preset = presets.find(p => p.name === activePresetName);
|
||||
var dirty = false;
|
||||
if (preset) {
|
||||
var currentPaths = new Set(window.app.selectedGroupingFolders);
|
||||
var presetPaths = new Set(preset.paths || []);
|
||||
dirty = currentPaths.size !== presetPaths.size ||
|
||||
!Array.from(currentPaths).every(p => presetPaths.has(p));
|
||||
}
|
||||
|
||||
if (isNamingMode) {
|
||||
footerHtml = (
|
||||
'<div class="preset-footer-naming">' +
|
||||
'<input type="text" class="preset-name-input" placeholder="Preset name" autoFocus>' +
|
||||
'<button class="preset-confirm-name btn btn-sm">✓</button>' +
|
||||
'<button class="preset-cancel-name btn btn-sm">✗</button>' +
|
||||
'</div>'
|
||||
);
|
||||
} else if (dirty) {
|
||||
footerHtml = (
|
||||
'<div class="preset-footer-actions">' +
|
||||
'<button class="preset-update-btn btn btn-primary btn-sm">Update "' + escapeHtml(activePresetName) + '"</button>' +
|
||||
'<button class="preset-save-new-btn btn btn-secondary btn-sm">Save as New</button>' +
|
||||
'</div>'
|
||||
);
|
||||
} else {
|
||||
footerHtml = (
|
||||
'<div class="preset-footer-actions">' +
|
||||
'<button class="preset-save-new-btn btn btn-primary btn-sm">Save as New</button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No active preset — disabled if nothing selected
|
||||
var selectedCount = window.app.selectedGroupingFolders.size;
|
||||
var disabledAttr = selectedCount === 0 ? ' disabled' : '';
|
||||
footerHtml = (
|
||||
'<div class="preset-footer-actions">' +
|
||||
'<button class="preset-save-btn btn btn-primary btn-sm' + (selectedCount === 0 ? ' btn-disabled' : '') + '" ' +
|
||||
'data-disabled="' + (selectedCount === 0 ? 'true' : 'false') + '">Save as Preset</button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
dropdown.innerHTML = (
|
||||
'<div class="preset-section-top">' +
|
||||
'<div class="preset-section-label">Saved Presets:</div>' +
|
||||
'<div class="preset-list">' + presetsHtml + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="preset-divider"></div>' +
|
||||
'<div class="preset-section-bottom">' +
|
||||
'<div class="preset-section-label">Projects:</div>' +
|
||||
'<div class="preset-projects-list">' + projectsHtml + '</div>' +
|
||||
'</div>' +
|
||||
footerHtml
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle dropdown visibility
|
||||
function toggleDropdown() {
|
||||
var dropdown = document.getElementById('presetDropdown');
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
isOpen = true;
|
||||
if (dropdown) dropdown.classList.remove('hidden');
|
||||
renderDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown
|
||||
function closeDropdown() {
|
||||
isOpen = false;
|
||||
var dropdown = document.getElementById('presetDropdown');
|
||||
if (dropdown) dropdown.classList.add('hidden');
|
||||
isNamingMode = false;
|
||||
}
|
||||
|
||||
// Set up event delegation on dropdown
|
||||
function setupDropdownDelegation() {
|
||||
var dropdown = document.getElementById('presetDropdown');
|
||||
if (!dropdown) return;
|
||||
|
||||
dropdown.addEventListener('click', function(e) {
|
||||
// Close on clicks inside dropdown
|
||||
e.stopPropagation();
|
||||
|
||||
// Preset item click — load preset (do NOT close dropdown)
|
||||
var presetItem = e.target.closest('.preset-item');
|
||||
if (presetItem && !e.target.classList.contains('preset-delete')) {
|
||||
var name = presetItem.getAttribute('data-name');
|
||||
if (name) loadPreset(name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete button
|
||||
var deleteBtn = e.target.closest('.preset-delete');
|
||||
if (deleteBtn) {
|
||||
e.stopPropagation();
|
||||
var name = deleteBtn.getAttribute('data-name');
|
||||
if (name) deletePreset(name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Checkbox click
|
||||
var checkbox = e.target.closest('.preset-checkbox');
|
||||
if (checkbox) {
|
||||
var path = checkbox.getAttribute('data-path');
|
||||
if (path) {
|
||||
if (checkbox.checked) {
|
||||
window.app.selectedGroupingFolders.add(path);
|
||||
} else {
|
||||
window.app.selectedGroupingFolders.delete(path);
|
||||
}
|
||||
window.app.modules.app.updateFolderSelectionState('groupingFoldersList');
|
||||
window.app.modules.app.renderTransmittalFolders();
|
||||
window.app.modules.filtering.applyFilters();
|
||||
checkDirty();
|
||||
renderButton();
|
||||
renderDropdown(); // Re-render to update checkbox states and footer
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Save button (not in naming mode)
|
||||
var saveBtn = e.target.closest('.preset-save-btn');
|
||||
if (saveBtn && !isNamingMode) {
|
||||
if (saveBtn.getAttribute('data-disabled') !== 'true') {
|
||||
isNamingMode = true;
|
||||
renderDropdown();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update button — save current selection as active preset
|
||||
var updateBtn = e.target.closest('.preset-update-btn');
|
||||
if (updateBtn) {
|
||||
if (activePresetName) savePreset(activePresetName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save as New button
|
||||
var saveNewBtn = e.target.closest('.preset-save-new-btn');
|
||||
if (saveNewBtn) {
|
||||
isNamingMode = true;
|
||||
renderDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm name input
|
||||
var confirmBtn = e.target.closest('.preset-confirm-name');
|
||||
if (confirmBtn) {
|
||||
var input = dropdown.querySelector('.preset-name-input');
|
||||
if (input && input.value.trim()) {
|
||||
savePreset(input.value.trim());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel name input
|
||||
var cancelBtn = e.target.closest('.preset-cancel-name');
|
||||
if (cancelBtn) {
|
||||
isNamingMode = false;
|
||||
renderDropdown();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Keydown on name input
|
||||
dropdown.addEventListener('keydown', function(e) {
|
||||
var input = e.target.closest('.preset-name-input');
|
||||
if (!input) return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
if (input.value.trim()) {
|
||||
savePreset(input.value.trim());
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
isNamingMode = false;
|
||||
renderDropdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle outside click to close dropdown
|
||||
function setupOutsideClickHandler() {
|
||||
document.addEventListener('click', function(e) {
|
||||
var section = document.getElementById('presetSection');
|
||||
var dropdown = document.getElementById('presetDropdown');
|
||||
if (isOpen && section && dropdown && !section.contains(e.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize presets module — called after first scan completes
|
||||
function init() {
|
||||
// Idempotent: skip if button listener already attached
|
||||
var btn = document.getElementById('presetBtn');
|
||||
if (!btn || btn.dataset.presetInit) return;
|
||||
btn.dataset.presetInit = '1';
|
||||
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
toggleDropdown();
|
||||
});
|
||||
|
||||
setupDropdownDelegation();
|
||||
setupOutsideClickHandler();
|
||||
loadFromStorage();
|
||||
renderButton();
|
||||
}
|
||||
|
||||
// Register module
|
||||
window.app.modules.presets = {
|
||||
init: init,
|
||||
loadPreset: loadPreset,
|
||||
savePreset: savePreset,
|
||||
deletePreset: deletePreset,
|
||||
checkDirty: checkDirty,
|
||||
renderButton: renderButton,
|
||||
toggleDropdown: toggleDropdown,
|
||||
closeDropdown: closeDropdown
|
||||
};
|
||||
|
||||
})();
|
||||
432
archive/js/source.js
Normal file
432
archive/js/source.js
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
// Source abstraction — local (File System Access API) and HTTP (Caddy JSON browse)
|
||||
//
|
||||
// Shared utility used by both source implementations
|
||||
function getDisplayPath(fullPath) {
|
||||
if (fullPath.length <= 100) {
|
||||
return fullPath;
|
||||
}
|
||||
const parts = fullPath.split('/');
|
||||
if (parts.length > 3) {
|
||||
return parts[0] + '/.../' + parts.slice(-2).join('/');
|
||||
}
|
||||
return '...' + fullPath.substring(fullPath.length - 80);
|
||||
}
|
||||
|
||||
// createSource(type, options) returns a source object:
|
||||
// source.type — 'local' | 'http'
|
||||
// source.canWrite — boolean
|
||||
// source.scan(rootIdentifier, callbacks) — Promise; walks tree calling:
|
||||
// callbacks.onGroupingFolder(folder) folder: { name, path, displayPath, handle? }
|
||||
// callbacks.onTransmittalFolder(folder) folder: { name, path, displayPath, handle?, url? }
|
||||
// callbacks.onFile(file) file: full file object (parsed + metadata)
|
||||
// callbacks.onProgress(message)
|
||||
// source.fetchFile(fileRef) — Promise<ArrayBuffer>
|
||||
|
||||
function createSource(type, options) {
|
||||
if (type === 'local') {
|
||||
return createLocalSource();
|
||||
} else if (type === 'http') {
|
||||
return createHttpSource(options.baseUrl);
|
||||
}
|
||||
throw new Error('Unknown source type: ' + type);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local source — wraps File System Access API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createLocalSource() {
|
||||
return {
|
||||
type: 'local',
|
||||
canWrite: true,
|
||||
|
||||
scan: function(dirHandle, callbacks) {
|
||||
return scanLocalRecursive(dirHandle, dirHandle.name, 0, callbacks);
|
||||
},
|
||||
|
||||
fetchFile: function(fileRef) {
|
||||
return fileRef.handle.getFile().then(function(f) {
|
||||
return f.arrayBuffer();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function scanLocalRecursive(dirHandle, currentPath, depth, callbacks) {
|
||||
if (currentPath.length > 200) {
|
||||
console.warn('Path too long, skipping deeper scan: ' + currentPath);
|
||||
return;
|
||||
}
|
||||
if (depth > 10) {
|
||||
console.warn('Directory depth limit reached at: ' + currentPath);
|
||||
return;
|
||||
}
|
||||
|
||||
callbacks.onProgress('Scanning ' + currentPath + '...');
|
||||
|
||||
const entries = [];
|
||||
try {
|
||||
for await (const entry of dirHandle.values()) {
|
||||
entries.push(entry);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not read directory ' + currentPath + ':', err);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'directory') {
|
||||
const subPath = currentPath + '/' + entry.name;
|
||||
try {
|
||||
if (window.app.modules.parser.isTransmittalFolder(entry.name)) {
|
||||
const folder = {
|
||||
name: entry.name,
|
||||
path: subPath,
|
||||
displayPath: getDisplayPath(subPath),
|
||||
handle: entry
|
||||
};
|
||||
callbacks.onTransmittalFolder(folder);
|
||||
await scanLocalTransmittalFolder(entry, subPath, 0, subPath, callbacks);
|
||||
} else {
|
||||
const folder = {
|
||||
name: entry.name,
|
||||
path: subPath,
|
||||
displayPath: entry.name,
|
||||
handle: entry
|
||||
};
|
||||
callbacks.onGroupingFolder(folder);
|
||||
await scanLocalRecursive(entry, subPath, depth + 1, callbacks);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not process directory ' + entry.name + ':', err);
|
||||
}
|
||||
} else if (entry.kind === 'file') {
|
||||
// File directly in a grouping folder — assign to the Outstanding virtual transmittal.
|
||||
// actualPath records the real containing folder for grouping-folder-scoped filtering.
|
||||
try {
|
||||
const file = await entry.getFile();
|
||||
const parsed = zddc.parseFilename(file.name) || {};
|
||||
|
||||
if (!parsed.trackingNumber) {
|
||||
console.warn('File does not match ZDDC naming convention: ' + file.name);
|
||||
}
|
||||
|
||||
const fullPath = currentPath + '/' + file.name;
|
||||
const displayPath = fullPath.length > 250
|
||||
? '...' + fullPath.substring(fullPath.length - 200)
|
||||
: fullPath;
|
||||
|
||||
callbacks.onFile({
|
||||
id: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
path: fullPath,
|
||||
displayPath: displayPath,
|
||||
url: null,
|
||||
size: file.size,
|
||||
modified: file.lastModified,
|
||||
handle: entry,
|
||||
folderPath: '__outstanding__',
|
||||
actualPath: currentPath,
|
||||
hasPathError: false,
|
||||
...parsed
|
||||
});
|
||||
} catch (fileErr) {
|
||||
const fullPath = currentPath + '/' + entry.name;
|
||||
const displayPath = fullPath.length > 250
|
||||
? '...' + fullPath.substring(fullPath.length - 200)
|
||||
: fullPath;
|
||||
const parsed = zddc.parseFilename(entry.name) || {};
|
||||
|
||||
callbacks.onFile({
|
||||
id: crypto.randomUUID(),
|
||||
name: entry.name,
|
||||
path: fullPath,
|
||||
displayPath: displayPath,
|
||||
url: null,
|
||||
size: null,
|
||||
modified: null,
|
||||
handle: null,
|
||||
folderPath: '__outstanding__',
|
||||
actualPath: currentPath,
|
||||
hasPathError: true,
|
||||
pathErrorMessage: fileErr.message || 'File access error',
|
||||
...parsed
|
||||
});
|
||||
|
||||
console.warn('Could not access file ' + entry.name + ' (path error):', fileErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function scanLocalTransmittalFolder(dirHandle, folderPath, depth, transmittalPath, callbacks) {
|
||||
if (depth > 10) {
|
||||
console.warn('Directory depth limit reached in transmittal folder: ' + folderPath);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (folderPath.length > 240) {
|
||||
console.warn('Path approaching Windows limit (' + folderPath.length + ' chars): ' + folderPath);
|
||||
}
|
||||
|
||||
for await (const entry of dirHandle.values()) {
|
||||
if (entry.kind === 'file') {
|
||||
try {
|
||||
const file = await entry.getFile();
|
||||
const parsed = zddc.parseFilename(file.name) || {};
|
||||
|
||||
if (!parsed.trackingNumber) {
|
||||
console.warn('File does not match ZDDC naming convention: ' + file.name);
|
||||
}
|
||||
|
||||
const fullPath = folderPath + '/' + file.name;
|
||||
const displayPath = fullPath.length > 250
|
||||
? '...' + fullPath.substring(fullPath.length - 200)
|
||||
: fullPath;
|
||||
|
||||
callbacks.onFile({
|
||||
id: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
path: fullPath,
|
||||
displayPath: displayPath,
|
||||
url: null,
|
||||
size: file.size,
|
||||
modified: file.lastModified,
|
||||
handle: entry,
|
||||
folderPath: transmittalPath,
|
||||
actualPath: folderPath,
|
||||
hasPathError: false,
|
||||
...parsed
|
||||
});
|
||||
} catch (fileErr) {
|
||||
const fullPath = folderPath + '/' + entry.name;
|
||||
const displayPath = fullPath.length > 250
|
||||
? '...' + fullPath.substring(fullPath.length - 200)
|
||||
: fullPath;
|
||||
const parsed = zddc.parseFilename(entry.name) || {};
|
||||
|
||||
callbacks.onFile({
|
||||
id: crypto.randomUUID(),
|
||||
name: entry.name,
|
||||
path: fullPath,
|
||||
displayPath: displayPath,
|
||||
url: null,
|
||||
size: null,
|
||||
modified: null,
|
||||
handle: null,
|
||||
folderPath: transmittalPath,
|
||||
actualPath: folderPath,
|
||||
hasPathError: true,
|
||||
pathErrorMessage: fileErr.message || 'File access error',
|
||||
...parsed
|
||||
});
|
||||
|
||||
console.warn('Could not access file ' + entry.name + ' (path error):', fileErr.message);
|
||||
}
|
||||
} else if (entry.kind === 'directory') {
|
||||
const subPath = folderPath + '/' + entry.name;
|
||||
try {
|
||||
await scanLocalTransmittalFolder(entry, subPath, depth + 1, transmittalPath, callbacks);
|
||||
} catch (err) {
|
||||
console.warn('Could not scan subdirectory ' + entry.name + ' in ' + folderPath + ':', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error scanning folder ' + folderPath + ':', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP source — uses Caddy JSON browse (Accept: application/json)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createHttpSource(baseUrl) {
|
||||
// Normalise: ensure baseUrl ends with /
|
||||
const root = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
|
||||
|
||||
return {
|
||||
type: 'http',
|
||||
canWrite: false,
|
||||
|
||||
scan: function(rootUrl, callbacks) {
|
||||
const scanRoot = (rootUrl && rootUrl !== root) ? rootUrl : root;
|
||||
return scanHttpRecursive(scanRoot, root, 0, null, callbacks);
|
||||
},
|
||||
|
||||
fetchFile: function(fileRef) {
|
||||
return fetch(fileRef.url).then(function(r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status + ' fetching ' + fileRef.url);
|
||||
return r.arrayBuffer();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function scanHttpRecursive(dirUrl, rootUrl, depth, transmittalPath, callbacks) {
|
||||
if (depth > 10) {
|
||||
console.warn('HTTP directory depth limit reached at: ' + dirUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
let items;
|
||||
try {
|
||||
// Caddy returns JSON when the Accept header requests it
|
||||
const resp = await fetch(dirUrl, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error('HTTP ' + resp.status + ' listing ' + dirUrl);
|
||||
}
|
||||
// Caddy encodes listing.Items directly — a bare JSON array of fileInfo objects
|
||||
// fileInfo fields: name (dirs have trailing "/"), size, url, mod_time, mode, is_dir, is_symlink
|
||||
items = await resp.json();
|
||||
if (!Array.isArray(items)) {
|
||||
throw new Error('Unexpected response format (expected JSON array)');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch directory listing for ' + dirUrl + ':', err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect subdirectory scan promises so siblings run in parallel
|
||||
const subdirPromises = [];
|
||||
|
||||
for (const item of items) {
|
||||
// Caddy appends "/" to directory names; strip it to get the bare name for matching
|
||||
const rawName = item.name.endsWith('/') ? item.name.slice(0, -1) : item.name;
|
||||
|
||||
// Skip hidden files
|
||||
if (rawName.startsWith('.')) continue;
|
||||
|
||||
const isDir = item.is_dir === true;
|
||||
// Project filter: at root depth, skip directories not in the allowed set
|
||||
if (depth === 0 && isDir && window.app.projectFilter && window.app.projectFilter.size > 0) {
|
||||
if (!window.app.projectFilter.has(rawName)) continue;
|
||||
}
|
||||
|
||||
const itemUrl = resolveHttpUrl(dirUrl, rawName, isDir);
|
||||
const logicalPath = urlToLogicalPath(itemUrl, rootUrl);
|
||||
|
||||
if (isDir) {
|
||||
if (transmittalPath !== null) {
|
||||
// Inside a transmittal folder — recurse into subdirectories
|
||||
subdirPromises.push(
|
||||
scanHttpRecursive(itemUrl, rootUrl, depth + 1, transmittalPath, callbacks)
|
||||
);
|
||||
} else if (window.app.modules.parser.isTransmittalFolder(rawName)) {
|
||||
const folder = {
|
||||
name: rawName,
|
||||
path: logicalPath,
|
||||
displayPath: getDisplayPath(logicalPath),
|
||||
handle: null,
|
||||
url: itemUrl
|
||||
};
|
||||
callbacks.onTransmittalFolder(folder);
|
||||
subdirPromises.push(
|
||||
scanHttpRecursive(itemUrl, rootUrl, depth + 1, logicalPath, callbacks)
|
||||
);
|
||||
} else {
|
||||
const folder = {
|
||||
name: rawName,
|
||||
path: logicalPath,
|
||||
displayPath: rawName,
|
||||
handle: null,
|
||||
url: itemUrl
|
||||
};
|
||||
callbacks.onGroupingFolder(folder);
|
||||
subdirPromises.push(
|
||||
scanHttpRecursive(itemUrl, rootUrl, depth + 1, null, callbacks)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// It's a file
|
||||
if (transmittalPath === null) {
|
||||
// File directly in a grouping folder — assign to Outstanding virtual transmittal.
|
||||
// actualPath records the real containing folder for grouping-folder-scoped filtering.
|
||||
const dirLogicalPath = urlToLogicalPath(dirUrl, rootUrl);
|
||||
const parsed = zddc.parseFilename(rawName) || {};
|
||||
if (!parsed.trackingNumber) {
|
||||
console.warn('File does not match ZDDC naming convention: ' + rawName);
|
||||
}
|
||||
|
||||
const modified = item.mod_time ? new Date(item.mod_time).getTime() : null;
|
||||
|
||||
callbacks.onFile({
|
||||
id: crypto.randomUUID(),
|
||||
name: rawName,
|
||||
path: logicalPath,
|
||||
displayPath: logicalPath,
|
||||
url: itemUrl,
|
||||
size: item.size || null,
|
||||
modified: modified,
|
||||
handle: null,
|
||||
folderPath: '__outstanding__',
|
||||
actualPath: dirLogicalPath,
|
||||
hasPathError: false,
|
||||
...parsed
|
||||
});
|
||||
} else {
|
||||
// Inside a transmittal folder
|
||||
const parsed = zddc.parseFilename(rawName) || {};
|
||||
if (!parsed.trackingNumber) {
|
||||
console.warn('File does not match ZDDC naming convention: ' + rawName);
|
||||
}
|
||||
|
||||
// mod_time is an ISO 8601 string from Go's time.Time.UTC()
|
||||
const modified = item.mod_time ? new Date(item.mod_time).getTime() : null;
|
||||
|
||||
callbacks.onFile({
|
||||
id: crypto.randomUUID(),
|
||||
name: rawName,
|
||||
path: logicalPath,
|
||||
displayPath: logicalPath,
|
||||
url: itemUrl,
|
||||
size: item.size || null,
|
||||
modified: modified,
|
||||
handle: null,
|
||||
folderPath: transmittalPath,
|
||||
actualPath: logicalPath.substring(0, logicalPath.lastIndexOf('/')),
|
||||
hasPathError: false,
|
||||
...parsed
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all sibling subdirectory scans to complete in parallel
|
||||
if (subdirPromises.length > 0) {
|
||||
await Promise.all(subdirPromises);
|
||||
}
|
||||
}
|
||||
|
||||
// Build an absolute URL for an item inside a directory listing URL
|
||||
function resolveHttpUrl(dirUrl, name, isDir) {
|
||||
const base = dirUrl.endsWith('/') ? dirUrl : dirUrl + '/';
|
||||
const encoded = encodeURIComponent(name);
|
||||
return base + encoded + (isDir ? '/' : '');
|
||||
}
|
||||
|
||||
// Convert an absolute item URL to a logical relative path (for display / filtering)
|
||||
function urlToLogicalPath(itemUrl, rootUrl) {
|
||||
const root = rootUrl.endsWith('/') ? rootUrl : rootUrl + '/';
|
||||
let rel = itemUrl;
|
||||
if (rel.startsWith(root)) {
|
||||
rel = rel.substring(root.length);
|
||||
}
|
||||
// Decode percent-encoding for display
|
||||
try { rel = decodeURIComponent(rel); } catch (e) { /* leave encoded */ }
|
||||
// Strip trailing slash for directories
|
||||
if (rel.endsWith('/')) rel = rel.slice(0, -1);
|
||||
return rel;
|
||||
}
|
||||
|
||||
window.app.modules.source = {
|
||||
getDisplayPath,
|
||||
createSource
|
||||
};
|
||||
|
||||
})();
|
||||
873
archive/js/table.js
Normal file
873
archive/js/table.js
Normal file
|
|
@ -0,0 +1,873 @@
|
|||
// Table management functionality
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
|
||||
// FileBlobCache, processedLinks, preview state, and utilities
|
||||
const fileBlobCache = new Map();
|
||||
const processedLinks = new WeakSet();
|
||||
let fileLinkHandlersAttached = false;
|
||||
let filePreviewWindow = null;
|
||||
const PREVIEW_EXTENSIONS = ['pdf', 'docx', 'xlsx', 'xls'];
|
||||
const loadedLibraries = new Map();
|
||||
let resizing = null;
|
||||
|
||||
|
||||
/**
|
||||
* Get or create a blob URL for a file.
|
||||
* - Local files: reads via File System Access API, caches the blob URL.
|
||||
* - HTTP files: fetches the remote URL, caches the blob URL.
|
||||
* Returns a Promise<string> resolving to a blob: URL.
|
||||
*/
|
||||
async function getFileBlobUrl(file) {
|
||||
if (fileBlobCache.has(file.id)) {
|
||||
return fileBlobCache.get(file.id);
|
||||
}
|
||||
let blob;
|
||||
if (file.handle) {
|
||||
// Local file via File System Access API
|
||||
const f = await file.handle.getFile();
|
||||
blob = f;
|
||||
} else if (file.url) {
|
||||
// HTTP file — fetch and convert to blob
|
||||
const resp = await fetch(file.url);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + file.url);
|
||||
blob = await resp.blob();
|
||||
} else {
|
||||
throw new Error('File has neither a handle nor a URL');
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
fileBlobCache.set(file.id, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up blob URLs for files no longer displayed
|
||||
*/
|
||||
function cleanupUnusedBlobUrls() {
|
||||
const displayedFileIds = new Set(window.app.filteredFiles.map(f => f.id));
|
||||
for (const [fileId, url] of fileBlobCache.entries()) {
|
||||
if (!displayedFileIds.has(fileId)) {
|
||||
URL.revokeObjectURL(url);
|
||||
fileBlobCache.delete(fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all blob URLs and clear cache
|
||||
*/
|
||||
function cleanupAllBlobUrls() {
|
||||
for (const url of fileBlobCache.values()) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
fileBlobCache.clear();
|
||||
}
|
||||
|
||||
// Update file table
|
||||
function updateFileTable() {
|
||||
const tbody = document.getElementById('filesTableBody');
|
||||
|
||||
if (window.app.filteredFiles.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="3" class="empty-table">
|
||||
No files found matching the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
cleanupUnusedBlobUrls(); // Clean up all blob URLs
|
||||
return;
|
||||
}
|
||||
|
||||
// Group and sort files
|
||||
const grouped = window.app.modules.parser.groupFilesByTrackingNumber(window.app.filteredFiles);
|
||||
const sorted = window.app.modules.parser.sortGroupedFiles(grouped);
|
||||
|
||||
// Build table rows
|
||||
const rows = [];
|
||||
sorted.forEach(group => {
|
||||
rows.push(createFileGroupRow(group));
|
||||
});
|
||||
|
||||
tbody.innerHTML = rows.join('');
|
||||
|
||||
// Clean up blob URLs for files no longer visible
|
||||
cleanupUnusedBlobUrls();
|
||||
}
|
||||
|
||||
// Create row for a file group
|
||||
function createFileGroupRow(group) {
|
||||
// Generate one <tr> per revision; last row gets class group-last for border
|
||||
const lastIndex = group.sortedRevisions.length - 1;
|
||||
return group.sortedRevisions.map((revision, i) => {
|
||||
const titleClass = revision.hasModifier ? 'revision-title-modifier' : 'revision-title-base';
|
||||
const titleHtml = `<div class="${titleClass}">${window.app.modules.app.escapeHtml(revision.title)}</div>`;
|
||||
const revisionHtml = createRevisionHtml(group.trackingNumber, revision);
|
||||
const lastClass = i === lastIndex ? ' group-last' : '';
|
||||
|
||||
// First row includes trackingNumber cell with rowspan
|
||||
if (i === 0) {
|
||||
return `
|
||||
<tr class="group-row${lastClass}">
|
||||
<td data-field="trackingNumber" rowspan="${group.sortedRevisions.length}">${window.app.modules.app.escapeHtml(group.trackingNumber)}</td>
|
||||
<td data-field="title">${titleHtml}</td>
|
||||
<td data-field="revisions">${revisionHtml}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
// Subsequent rows omit trackingNumber cell
|
||||
return `
|
||||
<tr class="group-row${lastClass}">
|
||||
<td data-field="title">${titleHtml}</td>
|
||||
<td data-field="revisions">${revisionHtml}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Create HTML for a revision
|
||||
function createRevisionHtml(trackingNumber, revision) {
|
||||
const filesHtml = revision.files.map(file =>
|
||||
createFileHtml(file)
|
||||
).join(' ');
|
||||
|
||||
return `
|
||||
<div class="revision-group">
|
||||
<div class="revision-item">
|
||||
<span class="revision-info">
|
||||
<span class="revision-id">${window.app.modules.app.escapeHtml(revision.revision)}</span>
|
||||
<span class="revision-status">(${window.app.modules.app.escapeHtml(revision.status)})</span>
|
||||
</span>
|
||||
${filesHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Create HTML for a file
|
||||
function createFileHtml(file) {
|
||||
const checked = window.app.selectedFiles.has(file.id) ? 'checked' : '';
|
||||
const fullPath = file.path || file.folderPath + '/' + file.name;
|
||||
|
||||
// Handle files with path errors (Windows 260-char limit)
|
||||
if (file.hasPathError) {
|
||||
const errorTitle = `⚠️ Cannot access: Microsoft Windows path length limit (260 chars)\n\nPath: ${fullPath}\n\nUse 'subst' to map archive to a drive letter, or shorten folder names.`;
|
||||
return `
|
||||
<span class="revision-file">
|
||||
<input type="checkbox"
|
||||
data-file-id="${file.id}"
|
||||
${checked}
|
||||
onchange="toggleFileSelection('${file.id}')">
|
||||
<span class="path-error-indicator" title="${window.app.modules.app.escapeHtml(errorTitle)}">⚠️</span>
|
||||
<span class="file-link-disabled"
|
||||
title="${window.app.modules.app.escapeHtml(errorTitle)}">
|
||||
<span class="file-ext">${window.app.modules.app.escapeHtml(file.extension.toUpperCase())}</span>
|
||||
${file.size != null ? `<span class="file-size">${window.app.modules.export.formatFileSize(file.size)}</span>` : ''}
|
||||
</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<span class="revision-file">
|
||||
<input type="checkbox"
|
||||
data-file-id="${file.id}"
|
||||
${checked}
|
||||
onchange="toggleFileSelection('${file.id}')">
|
||||
<a href="#"
|
||||
class="file-link"
|
||||
data-file-id="${file.id}"
|
||||
data-file-name="${window.app.modules.app.escapeHtml(file.name)}"
|
||||
title="${window.app.modules.app.escapeHtml(fullPath)}">
|
||||
<span class="file-ext">${window.app.modules.app.escapeHtml(file.extension.toUpperCase())}</span>
|
||||
${file.size != null ? `<span class="file-size">${window.app.modules.export.formatFileSize(file.size)}</span>` : ''}
|
||||
</a>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Toggle file selection
|
||||
function toggleFileSelection(fileId) {
|
||||
if (window.app.selectedFiles.has(fileId)) {
|
||||
window.app.selectedFiles.delete(fileId);
|
||||
} else {
|
||||
window.app.selectedFiles.add(fileId);
|
||||
}
|
||||
window.app.modules.app.updateStatusBar();
|
||||
updateSelectAllVisibleCheckbox();
|
||||
}
|
||||
|
||||
// Toggle selection of all visible files based on checkbox state
|
||||
function toggleSelectAllVisible(selectAll) {
|
||||
window.app.filteredFiles.forEach(file => {
|
||||
if (selectAll) {
|
||||
window.app.selectedFiles.add(file.id);
|
||||
} else {
|
||||
window.app.selectedFiles.delete(file.id);
|
||||
}
|
||||
});
|
||||
|
||||
updateFileTable();
|
||||
window.app.modules.app.updateStatusBar();
|
||||
updateSelectAllVisibleCheckbox();
|
||||
}
|
||||
|
||||
// Update the select all visible checkbox to reflect current state
|
||||
function updateSelectAllVisibleCheckbox() {
|
||||
const checkbox = document.getElementById('selectAllVisibleCheckbox');
|
||||
if (!checkbox) return;
|
||||
|
||||
const visibleCount = window.app.filteredFiles.length;
|
||||
if (visibleCount === 0) {
|
||||
checkbox.checked = false;
|
||||
checkbox.indeterminate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedVisibleCount = window.app.filteredFiles.filter(f =>
|
||||
window.app.selectedFiles.has(f.id)
|
||||
).length;
|
||||
|
||||
if (selectedVisibleCount === 0) {
|
||||
checkbox.checked = false;
|
||||
checkbox.indeterminate = false;
|
||||
} else if (selectedVisibleCount === visibleCount) {
|
||||
checkbox.checked = true;
|
||||
checkbox.indeterminate = false;
|
||||
} else {
|
||||
checkbox.checked = false;
|
||||
checkbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory-efficient blob URL management
|
||||
*
|
||||
* fileBlobCache: Maps file IDs to blob URLs for reuse
|
||||
* processedLinks: WeakSet tracks DOM elements that already have blob URLs
|
||||
* - Automatically garbage collected when DOM elements are removed
|
||||
* - Prevents redundant async operations on mouseover
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file preview mode is enabled
|
||||
*/
|
||||
function isFilePreviewEnabled() {
|
||||
const toggle = document.getElementById('filePreviewToggle');
|
||||
return toggle && toggle.checked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show file preview in a separate popup window
|
||||
* Supports PDF (iframe), DOCX (docx-preview), XLSX/XLS (SheetJS)
|
||||
*/
|
||||
async function showFilePreview(file) {
|
||||
const ext = file.extension.toLowerCase();
|
||||
|
||||
try {
|
||||
const url = await getFileBlobUrl(file);
|
||||
|
||||
// Mirror the parent window's theme in the popup
|
||||
const parentTheme = document.documentElement.getAttribute('data-theme') || '';
|
||||
const themeAttr = parentTheme ? ` data-theme="${parentTheme}"` : '';
|
||||
|
||||
// Base HTML shell for the preview window
|
||||
const previewHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html${themeAttr}>
|
||||
<head>
|
||||
<title>${window.app.modules.app.escapeHtml(file.name)} - Preview</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-secondary: #f5f5f5;
|
||||
--bg-hover: #e8e8e8;
|
||||
--text: #212529;
|
||||
--text-muted: #666666;
|
||||
--border: #dddddd;
|
||||
--primary: #2a5a8a;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #1e1e1e;
|
||||
--bg-secondary: #252526;
|
||||
--bg-hover: #2d2d30;
|
||||
--text: #d4d4d4;
|
||||
--text-muted: #9d9d9d;
|
||||
--border: #3e3e42;
|
||||
--primary: #4a90c4;
|
||||
}
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--bg: #1e1e1e;
|
||||
--bg-secondary: #252526;
|
||||
--bg-hover: #2d2d30;
|
||||
--text: #d4d4d4;
|
||||
--text-muted: #9d9d9d;
|
||||
--border: #3e3e42;
|
||||
--primary: #4a90c4;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--bg: #ffffff;
|
||||
--bg-secondary: #f5f5f5;
|
||||
--bg-hover: #e8e8e8;
|
||||
--text: #212529;
|
||||
--text-muted: #666666;
|
||||
--border: #dddddd;
|
||||
--primary: #2a5a8a;
|
||||
}
|
||||
* { 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;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toolbar h1 {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
.btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { background: var(--bg-hover); }
|
||||
iframe {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
#previewContent {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
/* docx-preview container */
|
||||
.docx-wrapper { padding: 1rem; }
|
||||
/* xlsx table styling */
|
||||
.xlsx-table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
|
||||
.xlsx-table th, .xlsx-table td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.35rem 0.5rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
.xlsx-table th { background: var(--bg-secondary); font-weight: 600; position: sticky; top: 0; }
|
||||
.xlsx-table tr:nth-child(even) { background: var(--bg-secondary); }
|
||||
.xlsx-table tr:hover { background: var(--bg-hover); }
|
||||
.sheet-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); background: var(--bg-secondary); }
|
||||
.sheet-tab {
|
||||
padding: 0.4rem 1rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
font-size: 0.85rem;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
.sheet-tab:hover { background: var(--bg-hover); }
|
||||
.sheet-tab.active {
|
||||
background: var(--bg);
|
||||
border-color: var(--border);
|
||||
border-bottom-color: var(--bg);
|
||||
margin-bottom: -1px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<h1>${window.app.modules.app.escapeHtml(file.name)}</h1>
|
||||
<button class="btn" onclick="downloadFile()">Download</button>
|
||||
</div>
|
||||
${ext === 'pdf' ? '<iframe src="' + url + '"></iframe>' : '<div id="previewContent"><div class="loading">Loading preview...</div></div>'}
|
||||
<script>
|
||||
var blobUrl = "${url}";
|
||||
var fileName = "${window.app.modules.app.escapeHtml(file.name).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>`;
|
||||
|
||||
// Open or reuse the preview window
|
||||
if (filePreviewWindow && !filePreviewWindow.closed) {
|
||||
filePreviewWindow.document.open();
|
||||
filePreviewWindow.document.write(previewHtml);
|
||||
filePreviewWindow.document.close();
|
||||
filePreviewWindow.focus();
|
||||
} else {
|
||||
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);
|
||||
|
||||
filePreviewWindow = window.open('', 'filePreview',
|
||||
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`);
|
||||
|
||||
if (!filePreviewWindow) {
|
||||
window.open(url, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
filePreviewWindow.document.write(previewHtml);
|
||||
filePreviewWindow.document.close();
|
||||
filePreviewWindow.focus();
|
||||
}
|
||||
|
||||
// For non-PDF types, render content into the preview window
|
||||
if (ext === 'docx') {
|
||||
await renderDocxInWindow(file);
|
||||
} else if (ext === 'xlsx' || ext === 'xls') {
|
||||
await renderXlsxInWindow(file);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading file preview:', err);
|
||||
alert(`Error loading preview: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a DOCX file in the preview window using docx-preview library
|
||||
*/
|
||||
async function renderDocxInWindow(file) {
|
||||
const container = filePreviewWindow.document.getElementById('previewContent');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
await loadLibrary('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js');
|
||||
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
|
||||
|
||||
const arrayBuffer = await (file.handle
|
||||
? file.handle.getFile().then(f => f.arrayBuffer())
|
||||
: fetch(file.url).then(r => r.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 = filePreviewWindow.document.getElementById('previewContent');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
|
||||
|
||||
const arrayBuffer = await (file.handle
|
||||
? file.handle.getFile().then(f => f.arrayBuffer())
|
||||
: fetch(file.url).then(r => r.arrayBuffer()));
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
// Build sheet tabs if multiple sheets
|
||||
if (workbook.SheetNames.length > 1) {
|
||||
const tabs = filePreviewWindow.document.createElement('div');
|
||||
tabs.className = 'sheet-tabs';
|
||||
workbook.SheetNames.forEach((name, i) => {
|
||||
const tab = filePreviewWindow.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');
|
||||
renderSheet(workbook, name, tableContainer);
|
||||
};
|
||||
tabs.appendChild(tab);
|
||||
});
|
||||
container.appendChild(tabs);
|
||||
}
|
||||
|
||||
const tableContainer = filePreviewWindow.document.createElement('div');
|
||||
tableContainer.style.flex = '1';
|
||||
tableContainer.style.overflow = 'auto';
|
||||
container.appendChild(tableContainer);
|
||||
|
||||
renderSheet(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
|
||||
*/
|
||||
function renderSheet(workbook, sheetName, container) {
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const html = XLSX.utils.sheet_to_html(sheet, { editable: false });
|
||||
container.innerHTML = html;
|
||||
// Apply styling to the generated table
|
||||
const table = container.querySelector('table');
|
||||
if (table) table.className = 'xlsx-table';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event delegation for file links
|
||||
* Left-click: Download file (or preview if PDF and preview mode enabled)
|
||||
* Right-click: Allow "Open in new tab" with blob URL
|
||||
*/
|
||||
function setupFileLinkHandlers() {
|
||||
if (fileLinkHandlersAttached) return;
|
||||
|
||||
const table = document.getElementById('filesTable');
|
||||
if (!table) {
|
||||
console.warn('Files table not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle clicks - download file or show preview
|
||||
table.addEventListener('click', async (e) => {
|
||||
const link = e.target.closest('.file-link');
|
||||
if (!link) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const fileId = link.getAttribute('data-file-id');
|
||||
const fileName = link.getAttribute('data-file-name');
|
||||
|
||||
if (!fileId || !fileName) {
|
||||
console.error('Invalid link data');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = window.app.files.find(f => f.id === fileId);
|
||||
|
||||
if (!file) {
|
||||
console.error(`File not found: ${fileId}`);
|
||||
alert('File not found. Please refresh and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file preview is enabled and file type is previewable
|
||||
if (isFilePreviewEnabled() && PREVIEW_EXTENSIONS.includes(file.extension.toLowerCase())) {
|
||||
await showFilePreview(file);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!file.handle && file.url) {
|
||||
// HTTP mode: open the file URL directly in a new tab
|
||||
window.open(file.url, '_blank');
|
||||
} else {
|
||||
// Local mode: create blob URL and trigger download
|
||||
const url = await getFileBlobUrl(file);
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.href = url;
|
||||
downloadLink.download = fileName;
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error opening file:', err);
|
||||
alert(`Error opening file: ${err.message}`);
|
||||
}
|
||||
}, true); // Use capture phase
|
||||
|
||||
// Handle mouseover - pre-load URL for fast right-click / middle-click
|
||||
table.addEventListener('mouseover', async (e) => {
|
||||
const link = e.target.closest('.file-link');
|
||||
if (!link) return;
|
||||
|
||||
// Skip if already processed (prevents redundant operations)
|
||||
if (processedLinks.has(link)) return;
|
||||
|
||||
const fileId = link.getAttribute('data-file-id');
|
||||
if (!fileId) return;
|
||||
|
||||
const file = window.app.files.find(f => f.id === fileId);
|
||||
if (!file) {
|
||||
console.warn(`File not found for pre-load: ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!file.handle && file.url) {
|
||||
// HTTP mode: set href directly — no async needed
|
||||
link.href = file.url;
|
||||
link.target = '_blank';
|
||||
processedLinks.add(link);
|
||||
} else {
|
||||
// Local mode: pre-load blob URL asynchronously
|
||||
const url = await getFileBlobUrl(file);
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
processedLinks.add(link);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error pre-loading file link:', err);
|
||||
// Don't mark as processed so it can retry
|
||||
}
|
||||
}, true); // Use capture phase
|
||||
|
||||
// Handle context menu - ensure blob URL is set (fallback if mouseover didn't fire)
|
||||
table.addEventListener('contextmenu', async (e) => {
|
||||
const link = e.target.closest('.file-link');
|
||||
if (!link) return;
|
||||
|
||||
// If already processed, blob URL is set - allow context menu to work
|
||||
if (processedLinks.has(link)) return;
|
||||
|
||||
const fileId = link.getAttribute('data-file-id');
|
||||
if (!fileId) return;
|
||||
|
||||
const file = window.app.files.find(f => f.id === fileId);
|
||||
if (!file) {
|
||||
console.warn(`File not found for context menu: ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get blob URL and set it as href synchronously as possible
|
||||
const url = await getFileBlobUrl(file);
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
|
||||
// Mark as processed
|
||||
processedLinks.add(link);
|
||||
} catch (err) {
|
||||
console.error('Error preparing file for context menu:', err);
|
||||
// Don't mark as processed so it can retry
|
||||
}
|
||||
}, true); // Use capture phase
|
||||
|
||||
fileLinkHandlersAttached = true;
|
||||
}
|
||||
|
||||
// Get MIME type from extension
|
||||
function getMimeType(extension) {
|
||||
const ext = extension.toLowerCase();
|
||||
const mimeTypes = {
|
||||
// Documents
|
||||
'pdf': 'application/pdf',
|
||||
'doc': 'application/msword',
|
||||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xls': 'application/vnd.ms-excel',
|
||||
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'ppt': 'application/vnd.ms-powerpoint',
|
||||
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
|
||||
// Text
|
||||
'txt': 'text/plain',
|
||||
'csv': 'text/csv',
|
||||
'html': 'text/html',
|
||||
'htm': 'text/html',
|
||||
'xml': 'text/xml',
|
||||
'json': 'application/json',
|
||||
|
||||
// Code
|
||||
'js': 'text/javascript',
|
||||
'css': 'text/css',
|
||||
'py': 'text/plain',
|
||||
'java': 'text/plain',
|
||||
'cpp': 'text/plain',
|
||||
'c': 'text/plain',
|
||||
'h': 'text/plain',
|
||||
|
||||
// Images
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'bmp': 'image/bmp',
|
||||
'svg': 'image/svg+xml',
|
||||
'webp': 'image/webp',
|
||||
'ico': 'image/x-icon',
|
||||
|
||||
// Archives
|
||||
'zip': 'application/zip',
|
||||
'rar': 'application/x-rar-compressed',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
'tar': 'application/x-tar',
|
||||
'gz': 'application/gzip',
|
||||
|
||||
// CAD
|
||||
'dwg': 'application/acad',
|
||||
'dxf': 'application/dxf',
|
||||
'dwf': 'model/vnd.dwf',
|
||||
'dgn': 'application/x-dgn',
|
||||
|
||||
// Other
|
||||
'mp4': 'video/mp4',
|
||||
'mp3': 'audio/mpeg',
|
||||
'wav': 'audio/wav',
|
||||
'avi': 'video/x-msvideo',
|
||||
'mov': 'video/quicktime',
|
||||
'md': 'text/markdown',
|
||||
'log': 'text/plain',
|
||||
'ini': 'text/plain',
|
||||
'cfg': 'text/plain',
|
||||
'conf': 'text/plain',
|
||||
'yaml': 'text/yaml',
|
||||
'yml': 'text/yaml'
|
||||
};
|
||||
|
||||
return mimeTypes[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
// Sort table
|
||||
function sortTable(field) {
|
||||
if (window.app.sortField === field) {
|
||||
// Toggle direction
|
||||
window.app.sortDirection = window.app.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// New field, default to ascending
|
||||
window.app.sortField = field;
|
||||
window.app.sortDirection = 'asc';
|
||||
}
|
||||
|
||||
updateSortIndicators();
|
||||
window.app.modules.filtering.applyFilters(); // Re-apply filters which will trigger table update
|
||||
window.app.modules.urlState.push();
|
||||
}
|
||||
|
||||
// Update sort indicators
|
||||
function updateSortIndicators() {
|
||||
// Remove all sort indicators
|
||||
document.querySelectorAll('th[data-sort]').forEach(th => {
|
||||
th.removeAttribute('data-sort');
|
||||
});
|
||||
|
||||
// Add current sort indicator
|
||||
const th = document.querySelector(`th[data-field="${window.app.sortField}"]`);
|
||||
if (th) {
|
||||
th.setAttribute('data-sort', window.app.sortDirection);
|
||||
}
|
||||
}
|
||||
|
||||
// Column resize functionality
|
||||
function initializeColumnResize() {
|
||||
const handles = document.querySelectorAll('.resize-handle');
|
||||
handles.forEach(handle => {
|
||||
handle.addEventListener('mousedown', startResize);
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', doResize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
}
|
||||
|
||||
function startResize(e) {
|
||||
const th = e.target.parentElement;
|
||||
resizing = {
|
||||
th: th,
|
||||
startX: e.clientX,
|
||||
startWidth: th.offsetWidth
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
function doResize(e) {
|
||||
if (! resizing) return;
|
||||
|
||||
const diff = e.clientX - resizing.startX;
|
||||
const newWidth = Math.max(100, resizing.startWidth + diff);
|
||||
resizing.th.style.width = newWidth + 'px';
|
||||
|
||||
// Update corresponding column
|
||||
const field = resizing.th.getAttribute('data-field');
|
||||
const cells = document.querySelectorAll(`td[data-field="${field}"]`);
|
||||
cells.forEach(cell => {
|
||||
cell.style.width = newWidth + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
if (resizing) {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
resizing = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle all files (Ctrl+A shortcut handler)
|
||||
// wrapper around toggleSelectAllVisible for keyboard shortcuts
|
||||
function toggleSelectAll() {
|
||||
toggleSelectAllVisible(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources when page unloads
|
||||
*/
|
||||
window.addEventListener('beforeunload', () => {
|
||||
cleanupAllBlobUrls();
|
||||
});
|
||||
|
||||
window.app.modules.table = {
|
||||
updateFileTable,
|
||||
toggleFileSelection,
|
||||
toggleSelectAllVisible,
|
||||
updateSelectAllVisibleCheckbox,
|
||||
setupFileLinkHandlers,
|
||||
updateSortIndicators,
|
||||
sortTable,
|
||||
initializeColumnResize
|
||||
};
|
||||
})();
|
||||
200
archive/js/url-state.js
Normal file
200
archive/js/url-state.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
// URL state sync module for ZDDC Archive
|
||||
|
||||
// Default values for URL params
|
||||
var DEFAULT_SORT_FIELD = 'trackingNumber';
|
||||
var DEFAULT_SORT_DIRECTION = 'asc';
|
||||
var DEFAULT_ENABLED_TYPES = ['issued', 'received'];
|
||||
|
||||
// Map URL param names to state paths
|
||||
var PARAM_MAP = {
|
||||
sort: 'sortField',
|
||||
dir: 'sortDirection',
|
||||
tn: 'columnFilters.trackingNumber',
|
||||
ti: 'columnFilters.title',
|
||||
rv: 'columnFilters.revisions',
|
||||
types: 'enabledFolderTypes',
|
||||
gf: 'groupingFilter',
|
||||
tf: 'transmittalFilter'
|
||||
};
|
||||
|
||||
// Serialize current state to URL query string
|
||||
function serialize() {
|
||||
var params = new URLSearchParams();
|
||||
|
||||
// Sort field
|
||||
if (window.app.sortField !== DEFAULT_SORT_FIELD) {
|
||||
params.set('sort', window.app.sortField);
|
||||
}
|
||||
|
||||
// Sort direction
|
||||
if (window.app.sortDirection !== DEFAULT_SORT_DIRECTION) {
|
||||
params.set('dir', window.app.sortDirection);
|
||||
}
|
||||
|
||||
// Column filters
|
||||
if (window.app.columnFilters.trackingNumber !== '') {
|
||||
params.set('tn', window.app.columnFilters.trackingNumber);
|
||||
}
|
||||
if (window.app.columnFilters.title !== '') {
|
||||
params.set('ti', window.app.columnFilters.title);
|
||||
}
|
||||
if (window.app.columnFilters.revisions !== '') {
|
||||
params.set('rv', window.app.columnFilters.revisions);
|
||||
}
|
||||
|
||||
// Folder types (only if different from default [issued, received])
|
||||
var enabledTypes = Array.from(window.app.enabledFolderTypes).sort();
|
||||
var defaultTypes = DEFAULT_ENABLED_TYPES.slice().sort();
|
||||
if (JSON.stringify(enabledTypes) !== JSON.stringify(defaultTypes)) {
|
||||
params.set('types', enabledTypes.join(','));
|
||||
}
|
||||
|
||||
// Grouping filter
|
||||
if (window.app.groupingFilter !== '') {
|
||||
params.set('gf', window.app.groupingFilter);
|
||||
}
|
||||
|
||||
// Transmittal filter
|
||||
if (window.app.transmittalFilter !== '') {
|
||||
params.set('tf', window.app.transmittalFilter);
|
||||
}
|
||||
|
||||
// Project filter — always preserved if set (for shareable URLs)
|
||||
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
|
||||
params.set('projects', Array.from(window.app.projectFilter).join(','));
|
||||
}
|
||||
|
||||
// Build query string
|
||||
var qs = params.toString();
|
||||
return qs ? '?' + qs : '';
|
||||
}
|
||||
|
||||
// Push state to URL without triggering popstate
|
||||
function push() {
|
||||
var result = serialize();
|
||||
if (result === location.search) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
history.replaceState(null, '', location.pathname + result);
|
||||
} catch (e) {
|
||||
// Silently swallow errors (e.g., file:// protocol restrictions)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore state from URL query string
|
||||
function restore() {
|
||||
var params = new URLSearchParams(location.search);
|
||||
|
||||
// Restore sort field
|
||||
if (params.has('sort')) {
|
||||
var sortValue = params.get('sort');
|
||||
if (sortValue === 'trackingNumber' || sortValue === 'title') {
|
||||
window.app.sortField = sortValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore sort direction
|
||||
if (params.has('dir')) {
|
||||
var dirValue = params.get('dir');
|
||||
if (dirValue === 'asc' || dirValue === 'desc') {
|
||||
window.app.sortDirection = dirValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore column filters with AST parsing
|
||||
if (params.has('tn')) {
|
||||
var tnValue = params.get('tn');
|
||||
window.app.columnFilters.trackingNumber = tnValue;
|
||||
window.app.columnFilterASTs.trackingNumber = zddc.filter.parse(tnValue);
|
||||
}
|
||||
if (params.has('ti')) {
|
||||
var tiValue = params.get('ti');
|
||||
window.app.columnFilters.title = tiValue;
|
||||
window.app.columnFilterASTs.title = zddc.filter.parse(tiValue);
|
||||
}
|
||||
if (params.has('rv')) {
|
||||
var rvValue = params.get('rv');
|
||||
window.app.columnFilters.revisions = rvValue;
|
||||
window.app.columnFilterASTs.revisions = zddc.filter.parse(rvValue);
|
||||
}
|
||||
|
||||
// Restore folder types
|
||||
if (params.has('types')) {
|
||||
var typesValue = params.get('types');
|
||||
var typeValues = typesValue.split(',').map(function(t) { return t.trim(); });
|
||||
// Validate against app.FOLDER_TYPE_NAMES
|
||||
var validTypes = typeValues.filter(function(t) {
|
||||
return window.app.FOLDER_TYPE_NAMES.indexOf(t) !== -1;
|
||||
});
|
||||
window.app.enabledFolderTypes = new Set(validTypes);
|
||||
}
|
||||
|
||||
// Restore grouping filter
|
||||
if (params.has('gf')) {
|
||||
window.app.groupingFilter = params.get('gf');
|
||||
}
|
||||
|
||||
// Restore transmittal filter
|
||||
if (params.has('tf')) {
|
||||
window.app.transmittalFilter = params.get('tf');
|
||||
}
|
||||
|
||||
// Restore project filter
|
||||
if (params.has('projects')) {
|
||||
var projValue = params.get('projects');
|
||||
var projNames = projValue.split(',').map(function(p) { return p.trim(); }).filter(Boolean);
|
||||
window.app.projectFilter = new Set(projNames);
|
||||
}
|
||||
|
||||
// Update DOM inputs to reflect restored values
|
||||
updateFilterInputs();
|
||||
}
|
||||
|
||||
// Update DOM filter inputs to match restored state
|
||||
function updateFilterInputs() {
|
||||
// Column filter inputs
|
||||
document.querySelectorAll('.column-filter[data-filter-field]').forEach(function(input) {
|
||||
var field = input.getAttribute('data-filter-field');
|
||||
var filterValue = window.app.columnFilters[field] || '';
|
||||
input.value = filterValue;
|
||||
if (filterValue !== '') {
|
||||
input.classList.add('filter-active');
|
||||
} else {
|
||||
input.classList.remove('filter-active');
|
||||
}
|
||||
});
|
||||
|
||||
// Grouping filter
|
||||
var groupingFilterEl = document.getElementById('groupingFilter');
|
||||
if (groupingFilterEl) {
|
||||
groupingFilterEl.value = window.app.groupingFilter;
|
||||
if (window.app.groupingFilter !== '') {
|
||||
groupingFilterEl.classList.add('filter-active');
|
||||
} else {
|
||||
groupingFilterEl.classList.remove('filter-active');
|
||||
}
|
||||
}
|
||||
|
||||
// Transmittal filter
|
||||
var transmittalFilterEl = document.getElementById('transmittalFilter');
|
||||
if (transmittalFilterEl) {
|
||||
transmittalFilterEl.value = window.app.transmittalFilter;
|
||||
if (window.app.transmittalFilter !== '') {
|
||||
transmittalFilterEl.classList.add('filter-active');
|
||||
} else {
|
||||
transmittalFilterEl.classList.remove('filter-active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register module
|
||||
window.app.modules.urlState = {
|
||||
serialize: serialize,
|
||||
push: push,
|
||||
restore: restore
|
||||
};
|
||||
|
||||
})();
|
||||
359
archive/template.html
Normal file
359
archive/template.html
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Archive</title>
|
||||
<style>
|
||||
{{CSS_PLACEHOLDER}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="appContainer">
|
||||
<!-- Project access warning banner (shown when URL contains inaccessible projects) -->
|
||||
<div id="projectWarningBanner" class="project-warning-banner hidden" role="alert">
|
||||
<span class="project-warning-text"></span>
|
||||
<button class="project-warning-dismiss" onclick="dismissProjectWarning()" aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;">⟳</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Container -->
|
||||
<div class="main-container">
|
||||
<!-- Navigation Pane -->
|
||||
<nav id="navigationPane" class="nav-pane">
|
||||
<!-- Grouping Folders Section -->
|
||||
<div class="nav-section" id="groupingSection">
|
||||
<div class="nav-section-header">
|
||||
<h3>Parties</h3>
|
||||
<div class="preset-section" id="presetSection">
|
||||
<button id="presetBtn" class="btn btn-secondary btn-sm" title="Party presets">▾ Presets</button>
|
||||
<div id="presetDropdown" class="preset-dropdown hidden"></div>
|
||||
</div>
|
||||
<button id="toggleGroupingBtn" class="btn-icon" title="Collapse/Expand">
|
||||
<span id="toggleGroupingIcon">▼</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="groupingContent" class="nav-section-content">
|
||||
<!-- Global folder type toggle bar -->
|
||||
<div id="folderTypeBar" class="folder-type-bar">
|
||||
<!-- Dynamically populated by renderFolderTypeBar() -->
|
||||
</div>
|
||||
<div class="filter-select-row">
|
||||
<input type="text"
|
||||
id="groupingFilter"
|
||||
class="filter-input"
|
||||
placeholder="Filter parties...">
|
||||
<label class="select-all-label select-all-inline" title="Auto-select all visible parties">
|
||||
<span>Select<br>All</span>
|
||||
<input type="checkbox" id="selectAllGroupingCheckbox" checked>
|
||||
</label>
|
||||
</div>
|
||||
<div id="groupingFoldersList" class="folder-list">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="resize-handle-vertical" data-resize="nav-sections"></div>
|
||||
</div>
|
||||
|
||||
<!-- Transmittal Folders Section -->
|
||||
<div class="nav-section" id="transmittalSection">
|
||||
<div class="nav-section-header">
|
||||
<h3>Transmittal Folders</h3>
|
||||
<button id="toggleAllDatesBtn" class="btn-icon" title="Expand/Collapse All">
|
||||
<span id="toggleAllDatesIcon">▼</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-select-row">
|
||||
<input type="text"
|
||||
id="transmittalFilter"
|
||||
class="filter-input"
|
||||
placeholder="Filter transmittal folders...">
|
||||
<label class="select-all-label select-all-inline" title="Auto-select all visible transmittals">
|
||||
<span>Select<br>All</span>
|
||||
<input type="checkbox" id="selectAllTransmittalsCheckbox" checked>
|
||||
</label>
|
||||
</div>
|
||||
<div id="transmittalFoldersList" class="folder-list">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="resize-handle-horizontal" data-resize="nav-pane"></div>
|
||||
</nav>
|
||||
|
||||
<!-- Content Area -->
|
||||
<main class="content-area">
|
||||
<!-- Content Header -->
|
||||
<div class="content-header">
|
||||
<!-- Reset Filters -->
|
||||
<button id="resetFiltersBtn" class="btn btn-secondary btn-icon-only" title="Reset all column filters">↺</button>
|
||||
|
||||
<!-- Preview toggle -->
|
||||
<label class="preview-toggle-label" title="Preview PDF, Word, and Excel files in a popup window instead of downloading">
|
||||
<input type="checkbox" id="filePreviewToggle">
|
||||
<span>Preview</span>
|
||||
</label>
|
||||
|
||||
<!-- Modifier Filter Dropdown -->
|
||||
<div class="modifier-filter-container">
|
||||
<button id="modifierFilterBtn" class="btn btn-secondary modifier-filter-btn">
|
||||
Modifiers ▼
|
||||
</button>
|
||||
<div id="modifierFilterDropdown" class="modifier-filter-dropdown hidden">
|
||||
<div class="modifier-filter-header">
|
||||
<label><input type="checkbox" id="modifierSelectAll" checked> Select All</label>
|
||||
</div>
|
||||
<div id="modifierFilterList" class="modifier-filter-list">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<div class="content-actions">
|
||||
<button id="filterSelectedBtn" class="btn btn-secondary">Filter Selected</button>
|
||||
<button id="downloadSelectedBtn" class="btn btn-secondary">Download (ZIP)</button>
|
||||
<button id="exportCsvBtn" class="btn btn-secondary">Export (CSV)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Files Table -->
|
||||
<div class="table-container">
|
||||
<table id="filesTable" class="files-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable resizable" data-field="trackingNumber">
|
||||
<div class="th-content">
|
||||
<span>Tracking Number</span>
|
||||
<span class="sort-indicator"></span>
|
||||
</div>
|
||||
<input type="text"
|
||||
class="column-filter"
|
||||
data-filter-field="trackingNumber"
|
||||
placeholder="Filter...">
|
||||
<div class="resize-handle"></div>
|
||||
</th>
|
||||
<th class="sortable resizable" data-field="title">
|
||||
<div class="th-content">
|
||||
<span>Title</span>
|
||||
<span class="sort-indicator"></span>
|
||||
</div>
|
||||
<input type="text"
|
||||
class="column-filter"
|
||||
data-filter-field="title"
|
||||
placeholder="Filter...">
|
||||
<div class="resize-handle"></div>
|
||||
</th>
|
||||
<th class="resizable" data-field="revisions">
|
||||
<div class="th-content" style="justify-content: flex-start;">
|
||||
<input type="checkbox"
|
||||
id="selectAllVisibleCheckbox"
|
||||
title="Select/deselect all visible files"
|
||||
style="margin-right: 0.5rem;">
|
||||
<span>Revisions</span>
|
||||
</div>
|
||||
<input type="text"
|
||||
class="column-filter"
|
||||
data-filter-field="revisions"
|
||||
placeholder="Filter...">
|
||||
<div class="resize-handle"></div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="filesTableBody">
|
||||
<!-- Dynamically populated -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="status-bar">
|
||||
<span id="fileCount">0 files</span>
|
||||
<span id="selectedCount">0 selected</span>
|
||||
<span id="scanStatus"></span><span id="scanSpinner" class="scan-spinner hidden"></span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Drop Modal -->
|
||||
<div id="dropModal" class="modal hidden">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Create Transmittal</h2>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Transmittal Folder Name:</label>
|
||||
<input type="text" id="transmittalName" class="form-input">
|
||||
<small class="form-help">Format: YYYY-MM-DD_TRACKINGNUMBER (STATUS) - TITLE</small>
|
||||
</div>
|
||||
<div class="files-preview">
|
||||
<h3>Files to Add:</h3>
|
||||
<table class="preview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Original Name</th>
|
||||
<th>New Name</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="filesPreviewBody">
|
||||
<!-- Dynamically populated -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary modal-cancel">Cancel</button>
|
||||
<button class="btn btn-primary modal-confirm">Create Transmittal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Directory Selected Message -->
|
||||
<div id="noDirectoryMessage" class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<h2>Welcome to ZDDC Archive</h2>
|
||||
<p>Click <strong>Add Local Directory</strong> to select an archive folder to browse.</p>
|
||||
<p>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p>
|
||||
<p><strong>How to navigate:</strong></p>
|
||||
<ul class="welcome-list">
|
||||
<li>Select a party to see their transmittal folders; toggle folder types (Issued, Received, MDL, Incoming) above the list</li>
|
||||
<li>Select transmittal folders to see their files</li>
|
||||
<li>Use <kbd>Ctrl+Click</kbd> to select multiple folders</li>
|
||||
<li>Use <kbd>Shift+Click</kbd> to select a range</li>
|
||||
<li><kbd>Ctrl+Click</kbd> chevrons to recursively expand/collapse</li>
|
||||
</ul>
|
||||
|
||||
<details class="windows-tip">
|
||||
<summary><strong>⚠️ Windows Path Length Deficiency</strong></summary>
|
||||
<div class="windows-tip__body">
|
||||
<p>Microsoft Windows has a legacy 260-character path limit that affects most applications. If you see "files skipped" warnings, use Microsoft's own workaround:</p>
|
||||
<ol>
|
||||
<li>Open Command Prompt as Administrator</li>
|
||||
<li>Map your archive to a short drive letter:<br>
|
||||
<code class="windows-tip__code">subst Z: "C:\Your\Long\Path\To\Archive"</code>
|
||||
</li>
|
||||
<li>Use the <strong>Z:</strong> drive in Archive Browser</li>
|
||||
<li>To remove later: <code>subst Z: /d</code></li>
|
||||
</ol>
|
||||
<p class="windows-tip__note">This limitation dates back to Windows 95. The mapping persists until reboot.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p class="note">Note: This application works entirely in your browser and does not transmit any data.</p>
|
||||
</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 Archive</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 Archive Browser?</h3>
|
||||
<p>The Archive Browser lets you search and retrieve files from a ZDDC-compliant archive stored on your local file system. Everything runs in your browser — no data is transmitted anywhere.</p>
|
||||
|
||||
<h3>Getting Started</h3>
|
||||
<ol>
|
||||
<li>When opened from a web server, the archive loads automatically from that server.</li>
|
||||
<li>Click <strong>Add Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li>
|
||||
<li>The browser scans for grouping folders and transmittal folders automatically.</li>
|
||||
<li>Select folders in the left panel to see their files in the main table.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Navigating Folders</h3>
|
||||
<p>The left panel has two sections:</p>
|
||||
<dl>
|
||||
<dt>Parties</dt>
|
||||
<dd>Top-level folders representing other parties. Select one or more to filter which transmittals are shown. Use the folder type buttons above the list to show or hide Issued, Received, MDL, and Incoming folder content.</dd>
|
||||
<dt>Transmittal Folders</dt>
|
||||
<dd>Grouped by date. Select one or more to filter which files appear in the table.</dd>
|
||||
</dl>
|
||||
<p><strong>Multi-select:</strong> Hold <kbd>Ctrl</kbd> and click to toggle individual folders. Hold <kbd>Shift</kbd> and click to select a range. <kbd>Ctrl+Click</kbd> a chevron (▶) to recursively expand or collapse all sub-folders.</p>
|
||||
|
||||
<h3>Searching and Filtering</h3>
|
||||
<dl>
|
||||
<dt>Column Filters</dt>
|
||||
<dd>Type in the filter row under each column header to filter by tracking number, title, or revision/status/extension. Filters support the expression syntax below. Active filters are highlighted in blue; use the ↺ reset button in the toolbar to clear all filters at once.</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>pdf !draft</code></dt>
|
||||
<dd>Contains "pdf" and not "draft"</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>
|
||||
<dl>
|
||||
<dt>Modifiers</dt>
|
||||
<dd>Use the Modifiers dropdown to show or hide files by revision modifier type (+B, +C, +N, +Q, or base).</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Downloading Files</h3>
|
||||
<dl>
|
||||
<dt>Download Selected (ZIP)</dt>
|
||||
<dd>Packages all checked files into a ZIP archive for download.</dd>
|
||||
<dt>Export Selected (CSV)</dt>
|
||||
<dd>Exports the visible file list as a CSV spreadsheet.</dd>
|
||||
<dt>File Preview</dt>
|
||||
<dd>When enabled, clicking a PDF, Word, or Excel file opens a preview popup instead of downloading it.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Keyboard Shortcuts</h3>
|
||||
<dl>
|
||||
<dt><kbd>Ctrl+A</kbd></dt>
|
||||
<dd>Select / deselect all visible files in the table.</dd>
|
||||
<dt><kbd>F5</kbd></dt>
|
||||
<dd>Refresh — rescan the current directory.</dd>
|
||||
<dt><kbd>Escape</kbd></dt>
|
||||
<dd>Close this help panel (or any open modal).</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Windows Path Length Note</h3>
|
||||
<p>Windows limits file paths to 260 characters by default. If files are skipped during scanning, map your archive to a short drive letter using <code>subst Z: "C:\Your\Long\Path"</code> in an Administrator Command Prompt, then open the <strong>Z:</strong> drive in the Archive Browser.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{{JS_PLACEHOLDER}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
125
bootstrap/README.md
Normal file
125
bootstrap/README.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Deployment bootstrap
|
||||
|
||||
ZDDC tools (archive, transmittal, classifier, mdedit, landing) are single-file
|
||||
HTML bundles. The bootstrap pattern lets you install once on a deployment and
|
||||
update by editing a few lines, without re-uploading multi-megabyte HTML files.
|
||||
|
||||
## The two-level model
|
||||
|
||||
A typical `zddc-server` deployment looks like this:
|
||||
|
||||
```
|
||||
<ZDDC_ROOT>/
|
||||
index.html # landing tool (or bootstrap)
|
||||
archive.html # archive tool (or bootstrap; site-wide channel switch lives here)
|
||||
transmittal.html
|
||||
classifier.html
|
||||
mdedit.html
|
||||
<project-A>/
|
||||
archive.html # level-1 bootstrap → fetches ../archive.html
|
||||
transmittal.html
|
||||
classifier.html
|
||||
mdedit.html
|
||||
<project files…>
|
||||
<project-B>/
|
||||
archive.html # level-1 bootstrap (or pinned to a specific version)
|
||||
…
|
||||
```
|
||||
|
||||
- **Level-1 stubs** at `<project>/<tool>.html` always fetch the same-origin
|
||||
`../<tool>.html`. They never touch `zddc.varasys.io`. Install them once;
|
||||
they don't need to change.
|
||||
- **At deployment root** (`<ZDDC_ROOT>/<tool>.html`), put either:
|
||||
- the actual built tool HTML — fully self-contained install, no external
|
||||
dependencies; or
|
||||
- a level-2 bootstrap — fetches a specific channel or pinned version from
|
||||
`https://zddc.varasys.io/releases/<tool>_<channel|v…>.html`.
|
||||
|
||||
The site administrator switches the whole site to a channel by replacing
|
||||
the file at `<ZDDC_ROOT>/<tool>.html` with the matching level-2 stub from
|
||||
`track-<channel>.zip`. A single project can override one tool by editing
|
||||
just `<project-X>/<tool>.html` (replace the relative `upstream` URL with an
|
||||
absolute zddc.varasys.io URL).
|
||||
|
||||
## Why two levels
|
||||
|
||||
The level-1 stubs let projects share a single source of truth for "which
|
||||
build of the archive tool runs here." Switching channels is one file change
|
||||
at the root; pinning a single project is one file change in that directory.
|
||||
|
||||
`document.write()` chains across both levels: level-1 fetches and writes,
|
||||
the new document's level-2 script runs and writes again, the third write
|
||||
is the actual tool. Origin stays at the deployment domain throughout, so
|
||||
File System Access API, `crypto.subtle`, and `localStorage` all work and
|
||||
preferences stay scoped to the deployment.
|
||||
|
||||
## Pinning options
|
||||
|
||||
There are two ways to choose a version: edit the stub for a permanent
|
||||
pin, or pass a `?v=` URL parameter for a per-request override.
|
||||
|
||||
### 1. Permanent pin (edit the stub)
|
||||
|
||||
Each stub has one `fallback`/`upstream` constant. Edit it once and the
|
||||
choice sticks for everyone using that file.
|
||||
|
||||
| URL | Behavior |
|
||||
|------------------------------------------------------------------|-----------------------------------------|
|
||||
| `https://zddc.varasys.io/releases/<tool>_latest.html` | current stable; auto-updates within stable |
|
||||
| `https://zddc.varasys.io/releases/<tool>_beta.html` | latest beta build (mutable) |
|
||||
| `https://zddc.varasys.io/releases/<tool>_alpha.html` | latest alpha build (mutable) |
|
||||
| `https://zddc.varasys.io/releases/<tool>_v1.2.3.html` | pinned to exact stable version |
|
||||
|
||||
### 2. Per-request `?v=` parameter
|
||||
|
||||
Both stub levels honor a `?v=` URL parameter. The parameter survives the
|
||||
`document.write()` chain, so it flows through level-1 → level-2 →
|
||||
upstream automatically.
|
||||
|
||||
| URL parameter | Behavior |
|
||||
|--------------------------------|-------------------------------------------------------|
|
||||
| `?v=0.0.4` (or `?v=v0.0.4`) | tries `<tool>_v0.0.4.html` locally, then upstream |
|
||||
| `?v=alpha` | switches to alpha channel |
|
||||
| `?v=beta` | switches to beta channel |
|
||||
| `?v=latest` | latest stable |
|
||||
| (omitted) | the default baked into the stub |
|
||||
|
||||
When level-1 has `?v=…`, it tries `../<tool>_<suffix>.html` first (useful
|
||||
when the admin has staged specific versions locally) and falls back to
|
||||
`../<tool>.html` if 404 — which then forwards the parameter via level-2
|
||||
if one is installed. So the same URL works whether the version is
|
||||
staged locally, served by a level-2 stub, or both.
|
||||
|
||||
Stable releases are immutable. Alpha and beta channel files are
|
||||
overwritten in place each time their channel is rebuilt; expect them to
|
||||
change without notice. The build label rendered on the tool page tells
|
||||
you what you are running (date + commit SHA for alpha/beta, version
|
||||
number for stable).
|
||||
|
||||
## Auditing what's installed
|
||||
|
||||
Every stub contains a `fallback` (level-1) or `upstream` (level-2)
|
||||
constant. To see what each tool / project on the deployment points at:
|
||||
|
||||
```sh
|
||||
grep -rn "fallback\|upstream" <ZDDC_ROOT>
|
||||
```
|
||||
|
||||
## CORS prerequisite (level-2 only)
|
||||
|
||||
A level-2 fetch is cross-origin (deployment → `zddc.varasys.io`). The
|
||||
upstream must serve `Access-Control-Allow-Origin: *` (or a list including
|
||||
your deployment origin) on the released HTML files. Verify with:
|
||||
|
||||
```sh
|
||||
curl -I https://zddc.varasys.io/releases/archive_latest.html | grep -i access-control
|
||||
```
|
||||
|
||||
Level-1 fetches are same-origin so no CORS is involved.
|
||||
|
||||
## Templates
|
||||
|
||||
`level1.html.tmpl` and `level2.html.tmpl` are the source of truth. The
|
||||
project's top-level `build.sh` substitutes `{{TOOL}}`, `{{TOOL_TITLE}}`,
|
||||
and `{{CHANNEL}}` to produce the per-tool stubs that ship in
|
||||
`install.zip` and `track-<channel>.zip`.
|
||||
73
bootstrap/level1.html.tmpl
Normal file
73
bootstrap/level1.html.tmpl
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Loading {{TOOL_TITLE}}…</title>
|
||||
<style>html,body{margin:0;font:14px system-ui,sans-serif;color:#666;padding:1rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
Loading…
|
||||
<script>
|
||||
// Level-1 bootstrap. Fetches the tool from the deployment root (same
|
||||
// origin) and document.write()s it in place. The target may be the real
|
||||
// built tool HTML or a level-2 bootstrap that fetches from upstream.
|
||||
//
|
||||
// URL parameter ?v= selects a specific version or channel:
|
||||
// ?v=0.0.4 (or v0.0.4) tries ../{{TOOL}}_v0.0.4.html locally
|
||||
// ?v=alpha|beta|latest tries ../{{TOOL}}_<channel>.html locally
|
||||
// (none) fetches ../{{TOOL}}.html (default)
|
||||
//
|
||||
// If the requested version is not staged locally, falls back to the
|
||||
// root entry (../{{TOOL}}.html) — which, if it is a level-2 stub,
|
||||
// forwards the param upstream to zddc.varasys.io. So ?v=0.0.4 works
|
||||
// whether admins staged it locally OR a level-2 stub is at root.
|
||||
//
|
||||
// To pin this single tool to a fixed version permanently, replace the
|
||||
// `fallback` constant below with an absolute URL like
|
||||
// https://zddc.varasys.io/releases/{{TOOL}}_v0.0.4.html.
|
||||
(async function () {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const v = params.get('v');
|
||||
const tool = '{{TOOL}}';
|
||||
const channels = { alpha: '_alpha', beta: '_beta', latest: '_latest', stable: '_latest' };
|
||||
|
||||
function suffixFor(value) {
|
||||
if (!value) return '';
|
||||
if (value in channels) return channels[value];
|
||||
const ver = value.startsWith('v') ? value.slice(1) : value;
|
||||
return '_v' + ver;
|
||||
}
|
||||
|
||||
const versioned = '../' + tool + suffixFor(v) + '.html';
|
||||
const fallback = '../' + tool + '.html';
|
||||
|
||||
async function fetchHTML(url) {
|
||||
const resp = await fetch(url, { cache: 'no-cache', credentials: 'omit' });
|
||||
if (!resp.ok) throw new Error(resp.status + ' ' + resp.statusText);
|
||||
return resp.text();
|
||||
}
|
||||
|
||||
try {
|
||||
let html;
|
||||
if (v && versioned !== fallback) {
|
||||
try {
|
||||
html = await fetchHTML(versioned);
|
||||
} catch (_) {
|
||||
// Versioned copy not staged locally — let the root entry forward
|
||||
// the request (level-2 stub will see ?v= and fetch upstream).
|
||||
html = await fetchHTML(fallback);
|
||||
}
|
||||
} else {
|
||||
html = await fetchHTML(fallback);
|
||||
}
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
} catch (err) {
|
||||
document.body.textContent = 'Failed to load {{TOOL_TITLE}}: ' + err.message;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
50
bootstrap/level2.html.tmpl
Normal file
50
bootstrap/level2.html.tmpl
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Loading {{TOOL_TITLE}}…</title>
|
||||
<style>html,body{margin:0;font:14px system-ui,sans-serif;color:#666;padding:1rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
Loading…
|
||||
<script>
|
||||
// Level-2 bootstrap. Fetches {{TOOL}} from zddc.varasys.io and
|
||||
// document.write()s it in place. The default upstream is the
|
||||
// {{CHANNEL}} channel; the URL parameter ?v= overrides it:
|
||||
//
|
||||
// ?v=alpha|beta|latest switches to that channel
|
||||
// ?v=0.0.4 (or v0.0.4) pins to that exact stable version
|
||||
// (none) uses the {{CHANNEL}} default
|
||||
//
|
||||
// Requires zddc.varasys.io to serve Access-Control-Allow-Origin: *.
|
||||
(async function () {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const v = params.get('v');
|
||||
const tool = '{{TOOL}}';
|
||||
const defaultChannel = '{{CHANNEL}}';
|
||||
const channels = { alpha: '_alpha', beta: '_beta', latest: '_latest', stable: '_latest' };
|
||||
|
||||
function suffixFor(value) {
|
||||
if (!value) return '_' + defaultChannel;
|
||||
if (value in channels) return channels[value];
|
||||
const ver = value.startsWith('v') ? value.slice(1) : value;
|
||||
return '_v' + ver;
|
||||
}
|
||||
|
||||
const upstream = 'https://zddc.varasys.io/releases/' + tool + suffixFor(v) + '.html';
|
||||
|
||||
try {
|
||||
const resp = await fetch(upstream, { cache: 'no-cache', credentials: 'omit' });
|
||||
if (!resp.ok) throw new Error(resp.status + ' ' + resp.statusText);
|
||||
const html = await resp.text();
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
} catch (err) {
|
||||
document.body.textContent = 'Failed to load from ' + upstream + ': ' + err.message;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
146
build.sh
Executable file
146
build.sh
Executable file
|
|
@ -0,0 +1,146 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Top-level build script — builds all ZDDC HTML tools, the zddc-server
|
||||
# binaries, and the downloadable bundles (install.zip and track-*.zip).
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
echo "=== Building ZDDC tools ==="
|
||||
|
||||
sh "$SCRIPT_DIR/transmittal/build.sh" "${1:-}" "${2:-}"
|
||||
sh "$SCRIPT_DIR/archive/build.sh" "${1:-}" "${2:-}"
|
||||
sh "$SCRIPT_DIR/classifier/build.sh" "${1:-}" "${2:-}"
|
||||
sh "$SCRIPT_DIR/mdedit/build.sh" "${1:-}" "${2:-}"
|
||||
sh "$SCRIPT_DIR/landing/build.sh" "${1:-}" "${2:-}"
|
||||
|
||||
echo ""
|
||||
echo "=== Building zddc-server binaries ==="
|
||||
mkdir -p "$SCRIPT_DIR/zddc/dist/web"
|
||||
podman build --target binaries -o "$SCRIPT_DIR/zddc/dist/" "$SCRIPT_DIR/zddc/" 2>&1 | grep -v "^-->"
|
||||
|
||||
echo ""
|
||||
echo "=== Assembling zddc/dist/web/ ==="
|
||||
# Only landing and archive ship inside the server bundle: they call the
|
||||
# server's JSON API (GET / for the project list, directory listings for the
|
||||
# archive) and are useless without it. transmittal, classifier, and mdedit
|
||||
# are pure client-side tools that work from file:// or any static host;
|
||||
# they are released to website/ for download but not bundled with the server.
|
||||
cp "$SCRIPT_DIR/landing/dist/index.html" "$SCRIPT_DIR/zddc/dist/web/index.html"
|
||||
cp "$SCRIPT_DIR/archive/dist/archive.html" "$SCRIPT_DIR/zddc/dist/web/archive.html"
|
||||
echo "Wrote zddc/dist/web/index.html"
|
||||
echo "Wrote zddc/dist/web/archive.html"
|
||||
|
||||
# ─── Bootstrap zips ──────────────────────────────────────────────────────────
|
||||
# Generated from bootstrap/level{1,2}.html.tmpl on every build so they are
|
||||
# always in sync with the current bootstrap pattern.
|
||||
#
|
||||
# install.zip — drop into deployment root for self-contained install.
|
||||
# Contains the 5 current-stable HTMLs at root plus a
|
||||
# _template/ directory with 4 level-1 stubs that
|
||||
# projects can use as their starting layout.
|
||||
# track-<channel>.zip — drop the level-2 stubs over deployment root to make
|
||||
# the whole site track <channel> from zddc.varasys.io.
|
||||
#
|
||||
# install.zip needs at least one stable release to exist under
|
||||
# website/releases/; if none exist yet, that zip is skipped with a warning.
|
||||
|
||||
WEBSITE_DIR="$SCRIPT_DIR/website"
|
||||
RELEASES_DIR="$WEBSITE_DIR/releases"
|
||||
BOOTSTRAP_DIR="$SCRIPT_DIR/bootstrap"
|
||||
|
||||
mkdir -p "$WEBSITE_DIR"
|
||||
|
||||
# tool|filename|title
|
||||
TOOL_TABLE='archive|archive.html|Archive
|
||||
transmittal|transmittal.html|Transmittal
|
||||
classifier|classifier.html|Classifier
|
||||
mdedit|mdedit.html|Markdown Editor
|
||||
landing|index.html|ZDDC'
|
||||
|
||||
# Substitute {{TOOL}}, {{TOOL_TITLE}}, {{CHANNEL}} in a template.
|
||||
# Substitute {{TOOL}}, {{TOOL_TITLE}}, {{CHANNEL}} in a template.
|
||||
render_stub() {
|
||||
sed \
|
||||
-e "s|{{TOOL_TITLE}}|$3|g" \
|
||||
-e "s|{{TOOL}}|$2|g" \
|
||||
-e "s|{{CHANNEL}}|${4:-}|g" \
|
||||
"$1" > "$5"
|
||||
}
|
||||
|
||||
build_install_zip() {
|
||||
# Verify a stable release exists for every tool before staging.
|
||||
_missing=""
|
||||
while IFS='|' read -r _tool _file _title; do
|
||||
[ -e "$RELEASES_DIR/${_tool}_latest.html" ] || _missing="$_missing $_tool"
|
||||
done <<EOF
|
||||
$TOOL_TABLE
|
||||
EOF
|
||||
|
||||
if [ -n "$_missing" ]; then
|
||||
echo "Skipping install.zip — no stable release for:$_missing"
|
||||
return 0
|
||||
fi
|
||||
|
||||
_staging=$(mktemp -d)
|
||||
while IFS='|' read -r _tool _file _title; do
|
||||
cp "$RELEASES_DIR/${_tool}_latest.html" "$_staging/$_file"
|
||||
done <<EOF
|
||||
$TOOL_TABLE
|
||||
EOF
|
||||
|
||||
# _template/ holds level-1 bootstraps for the four interactive tools
|
||||
# (landing only lives at deployment root; project directories do not
|
||||
# have their own landing page).
|
||||
mkdir -p "$_staging/_template"
|
||||
while IFS='|' read -r _tool _file _title; do
|
||||
render_stub "$BOOTSTRAP_DIR/level1.html.tmpl" "$_tool" "$_title" "" "$_staging/_template/$_file"
|
||||
done <<EOF
|
||||
archive|archive.html|Archive
|
||||
transmittal|transmittal.html|Transmittal
|
||||
classifier|classifier.html|Classifier
|
||||
mdedit|mdedit.html|Markdown Editor
|
||||
EOF
|
||||
|
||||
cp "$BOOTSTRAP_DIR/README.md" "$_staging/README.md"
|
||||
|
||||
rm -f "$WEBSITE_DIR/install.zip"
|
||||
(cd "$_staging" && zip -qr "$WEBSITE_DIR/install.zip" .)
|
||||
echo "Wrote $WEBSITE_DIR/install.zip"
|
||||
rm -rf "$_staging"
|
||||
}
|
||||
|
||||
build_track_zip() {
|
||||
_channel="$1"
|
||||
_staging=$(mktemp -d)
|
||||
while IFS='|' read -r _tool _file _title; do
|
||||
render_stub "$BOOTSTRAP_DIR/level2.html.tmpl" "$_tool" "$_title" "$_channel" "$_staging/$_file"
|
||||
done <<EOF
|
||||
$TOOL_TABLE
|
||||
EOF
|
||||
|
||||
rm -f "$WEBSITE_DIR/track-$_channel.zip"
|
||||
(cd "$_staging" && zip -qr "$WEBSITE_DIR/track-$_channel.zip" .)
|
||||
echo "Wrote $WEBSITE_DIR/track-$_channel.zip"
|
||||
rm -rf "$_staging"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== Building install.zip and track-*.zip ==="
|
||||
build_install_zip
|
||||
build_track_zip alpha
|
||||
build_track_zip beta
|
||||
build_track_zip latest
|
||||
|
||||
echo ""
|
||||
echo "=== All tools built successfully ==="
|
||||
echo ""
|
||||
echo "Server deployment package: zddc/dist/"
|
||||
echo " Binaries: zddc-server-{linux,darwin,windows}-*"
|
||||
echo " Web files: web/ (copy contents to ZDDC_ROOT)"
|
||||
echo ""
|
||||
echo "Bootstrap downloads: website/"
|
||||
echo " install.zip — self-contained install for deployment root"
|
||||
echo " track-alpha.zip — level-2 stubs that track the alpha channel"
|
||||
echo " track-beta.zip — level-2 stubs that track the beta channel"
|
||||
echo " track-latest.zip — level-2 stubs that track the latest stable"
|
||||
244
classifier/README.md
Normal file
244
classifier/README.md
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
# Document Classifier
|
||||
|
||||
[← Back to ZDDC](../README.md)
|
||||
|
||||
Turn chaos into order - a spreadsheet-like tool for bulk renaming files to ZDDC format. Copy/paste with Excel for lightning-fast text operations. The entire app fits in a single HTML file that works forever.
|
||||
|
||||
**[🔗 Open Document Classifier](dist/classifier.html)** - Click to use online, or right-click → "Save Link As" to keep your own copy.
|
||||
|
||||
## Why This Tool?
|
||||
|
||||
Got a folder full of "spec_final_v2_REALLY_FINAL.pdf" files? This tool transforms them into properly named, searchable documents. It's like Excel for file names - edit hundreds at once, paste from spreadsheets, and save hours of manual renaming.
|
||||
|
||||
## What You Can Do
|
||||
|
||||
📋 **Excel-Like Editing**
|
||||
- Edit file names like a spreadsheet
|
||||
- Copy/paste entire columns to/from Excel
|
||||
- Select ranges just like Excel (click and drag)
|
||||
- Tab through cells, sort columns, resize as needed
|
||||
|
||||
🚀 **Bulk Operations**
|
||||
- Rename hundreds of files in seconds
|
||||
- Auto-populate from existing ZDDC names
|
||||
- Hide already-compliant files to focus on the rest
|
||||
- Save all changes with one click
|
||||
|
||||
🎯 **Smart Features**
|
||||
- Real-time validation shows errors instantly
|
||||
- Files stay in their folders - just get new names
|
||||
- Preview any file with a single click
|
||||
- Automatic folder expansion shows everything at once
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Click "Select Directory"** - Pick the folder with messy file names
|
||||
2. **See the magic** - Files appear in a spreadsheet, auto-parsed if already ZDDC
|
||||
3. **Double-click to edit** - Just like Excel, or paste from a spreadsheet
|
||||
4. **Click "Save All"** - All files renamed instantly
|
||||
5. **That's it!** - Your files are now organized and searchable
|
||||
|
||||
## ZDDC Naming Convention
|
||||
|
||||
### File Format
|
||||
```
|
||||
trackingNumber_revision (status) - title.extension
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
CE-BYR-ALL-EL-SPC-0001_A (IFC) - Cable Specification.pdf
|
||||
```
|
||||
|
||||
**Components:**
|
||||
- **Tracking Number**: Project identifier (e.g., CE-BYR-ALL-EL-SPC-0001)
|
||||
- **Revision**: Document revision (e.g., A, B, 0, 1, A+C1)
|
||||
- **Status**: Status code (IFC, IFR, IFI, AFD, AFC, ASB, etc.)
|
||||
- **Title**: Descriptive title
|
||||
- **Extension**: File type (.pdf, .docx, .dwg, etc.)
|
||||
|
||||
### Folder Format (Transmittal)
|
||||
```
|
||||
YYYY-MM-DD_trackingNumber (status) - title
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
2024-10-15_CE-BYR-ALL (IFC) - October Specifications
|
||||
```
|
||||
|
||||
## How to Use
|
||||
|
||||
### 1. Select Directory
|
||||
Click "Select Directory" and choose the root folder containing files to organize. The tool will:
|
||||
- Scan all subdirectories
|
||||
- Expand all folders automatically
|
||||
- Load all files into the spreadsheet
|
||||
- Parse existing ZDDC filenames into editable fields (shown in gray)
|
||||
|
||||
### 2. Navigate and Filter
|
||||
|
||||
**Folder Tree:**
|
||||
- All folders start expanded
|
||||
- Click folder names to select/deselect
|
||||
- Click ▼/▶ icons to collapse/expand
|
||||
- Selected folders show their files in the spreadsheet
|
||||
|
||||
**Filtering:**
|
||||
- Type in column header filter boxes to filter rows
|
||||
- Check "Hide Compliant Files" to focus on non-compliant files only
|
||||
- Sort by clicking column headers (Shift+Click for multi-column sort)
|
||||
|
||||
### 3. Edit Files
|
||||
|
||||
**Spreadsheet Interface:**
|
||||
- Auto-populated fields appear in gray italic text
|
||||
- Double-click any cell to edit
|
||||
- Press Enter or Tab to move to next cell
|
||||
- Changes mark the row as modified (✓ ✗ buttons appear)
|
||||
|
||||
**Excel Integration:**
|
||||
1. Select cells (click and drag)
|
||||
2. Copy (Ctrl+C) and paste into Excel
|
||||
3. Edit in Excel (e.g., convert to proper case)
|
||||
4. Copy from Excel and paste back (Ctrl+V)
|
||||
5. Click "Save All" to apply all changes
|
||||
|
||||
### 4. Save Changes
|
||||
|
||||
**Individual Files:**
|
||||
- Edit fields for a file
|
||||
- Click ✓ button to save that file
|
||||
- Click ✗ button to cancel changes
|
||||
|
||||
**Batch Save:**
|
||||
- Edit multiple files
|
||||
- Click "Save All" button to rename all modified files at once
|
||||
- Click "Cancel All" to discard all changes
|
||||
|
||||
### 5. Preview Files
|
||||
- Click any filename link to open the file in a new tab
|
||||
- Works with PDFs, images, and browser-viewable files
|
||||
|
||||
### 6. Keyboard Shortcuts
|
||||
- **Tab**: Move to next cell
|
||||
- **Shift+Tab**: Move to previous cell
|
||||
- **Enter**: Move down one row
|
||||
- **Escape**: Cancel editing
|
||||
- **Ctrl+A** (in tree): Select all visible folders
|
||||
- **Ctrl+S**: Save all (when files are modified)
|
||||
|
||||
## Common Status Codes
|
||||
|
||||
- **IFR** - Issued for Review
|
||||
- **IFC** - Issued for Construction
|
||||
- **IFI** - Issued for Information
|
||||
- **AFD** - Approved for Design
|
||||
- **AFC** - Approved for Construction
|
||||
- **ASB** - As-Built
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### 🔧 Fix ALL CAPS Titles
|
||||
Have files like "CABLE SPECIFICATION.PDF"? Use the Excel trick:
|
||||
1. Select the Title column → Copy to Excel
|
||||
2. Use `=PROPER(A1)` to fix casing
|
||||
3. Paste back → Save All → Done!
|
||||
|
||||
### 📝 Organize Random Files
|
||||
Transform `spec001.pdf` → `CE-BYR-ALL-EL-SPC-0001_A (IFC) - Cable Specification.pdf`
|
||||
- Just fill in the spreadsheet cells
|
||||
- Copy/paste common values
|
||||
- Save All when ready
|
||||
|
||||
### 🔄 Update Revisions
|
||||
Change revision A to B across multiple files:
|
||||
- Gray text = current values
|
||||
- Edit only what needs changing
|
||||
- Batch save or save individually
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
Requires:
|
||||
- Modern Chromium-based browser (Chrome, Edge, Brave, etc.)
|
||||
- File System Access API support
|
||||
- JavaScript enabled
|
||||
|
||||
## Privacy & Security
|
||||
|
||||
- All processing happens locally in your browser
|
||||
- No data transmitted to any server
|
||||
- File System Access API requires explicit user permission
|
||||
- No tracking or analytics
|
||||
|
||||
## Limitations
|
||||
|
||||
### Folder Renaming
|
||||
Due to browser API limitations, folders cannot be renamed directly. The tool will provide the correct folder name format for manual renaming in your file system.
|
||||
|
||||
### File Preview
|
||||
- PDFs open in browser tab
|
||||
- Other file types download for viewing in native applications
|
||||
- Browser cannot preview most file formats
|
||||
|
||||
### File System Access
|
||||
- Requires user permission for each directory
|
||||
- Some file systems may have restrictions
|
||||
- Network drives may not be fully supported
|
||||
|
||||
## Tips for Efficient Use
|
||||
|
||||
1. **Use Hide Compliant Filter**: Check to focus only on non-compliant files
|
||||
2. **Excel Integration**: Copy/paste columns to Excel for bulk text operations
|
||||
3. **Auto-Population**: Gray fields are auto-parsed - edit only what needs changing
|
||||
4. **Column Sorting**: Click headers to sort, Shift+Click for multi-level sorting
|
||||
5. **Column Filtering**: Type in header filter boxes to narrow down files
|
||||
6. **Resize Columns**: Drag column borders to see full content
|
||||
7. **Save All**: Edit multiple files then save all at once
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Permission denied" errors
|
||||
- Ensure you've granted browser permission
|
||||
- Try selecting the directory again
|
||||
- Check file system permissions
|
||||
|
||||
### Files not appearing
|
||||
- Click "Refresh" button to rescan
|
||||
- Ensure files aren't hidden by OS
|
||||
- Check that directory handle is valid
|
||||
|
||||
### Rename fails
|
||||
- Check if file with that name already exists
|
||||
- Ensure file isn't open in another application
|
||||
- Verify you have write permissions
|
||||
|
||||
### Gray fields not appearing
|
||||
- Fields only auto-populate if filename matches ZDDC pattern
|
||||
- Pattern: `TRACKING_REV (STATUS) - TITLE.ext`
|
||||
|
||||
## Technical Details
|
||||
|
||||
- **Architecture**: Single-page HTML application with centralized state management
|
||||
- **API**: File System Access API (Chromium browsers only)
|
||||
- **Build**: Concatenated from modular source files
|
||||
- **No Dependencies**: Pure vanilla JavaScript
|
||||
- **State Management**: Event-driven store pattern for predictable updates
|
||||
|
||||
## Development
|
||||
|
||||
Build: `sh build.sh` produces `dist/classifier.html` — a single self-contained file with all CSS, JS, and shared modules inlined.
|
||||
|
||||
The canonical CSS and JS load order lives in `build.sh`. See the root `ARCHITECTURE.md` for the build system and module pattern, and `AGENTS.md` for shared helpers (`shared/zddc.js`, `shared/zddc-filter.js`, `shared/theme.js`, `shared/help.js`) and ZDDC parser API.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
This tool follows simple, reliable best practices:
|
||||
- **Single Purpose**: Classify and rename files to ZDDC format
|
||||
- **Spreadsheet Paradigm**: Familiar Excel-like interface for batch editing
|
||||
- **Simplicity = Reliability**: Centralized state, unidirectional data flow
|
||||
- **No Magic**: Deterministic, user-controlled operations with live validation
|
||||
- **Portable**: Single HTML file, works offline, no server required
|
||||
- **Excel Integration**: Copy/paste workflow for bulk text operations
|
||||
|
||||
Use this tool to prepare files for the ZDDC Archive Browser.
|
||||
86
classifier/build.sh
Normal file
86
classifier/build.sh
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
root_dir=$(cd "$(dirname "$0")" && pwd)
|
||||
. "$root_dir/../shared/build-lib.sh"
|
||||
|
||||
src_html="$root_dir/template.html"
|
||||
output_dir="$root_dir/dist"
|
||||
output_html="$output_dir/classifier.html"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
ensure_exists "$src_html"
|
||||
|
||||
css_temp=$(mktemp)
|
||||
js_raw=$(mktemp)
|
||||
js_temp=$(mktemp)
|
||||
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# CSS files to concatenate in order
|
||||
concat_files \
|
||||
"../shared/base.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
"css/spreadsheet.css" \
|
||||
> "$css_temp"
|
||||
|
||||
# JavaScript files to concatenate in order
|
||||
concat_files \
|
||||
"../shared/zddc.js" \
|
||||
"../shared/hash.js" \
|
||||
"../shared/theme.js" \
|
||||
"js/app.js" \
|
||||
"js/utils.js" \
|
||||
"../shared/zddc-filter.js" \
|
||||
"js/store.js" \
|
||||
"js/validator.js" \
|
||||
"js/scanner.js" \
|
||||
"js/tree.js" \
|
||||
"js/spreadsheet.js" \
|
||||
"js/selection.js" \
|
||||
"js/preview.js" \
|
||||
"js/resize.js" \
|
||||
"js/filter.js" \
|
||||
"js/sort.js" \
|
||||
"js/excel.js" \
|
||||
"../shared/help.js" \
|
||||
> "$js_raw"
|
||||
|
||||
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
||||
# for a closing </script> tag. Required for any tool with template literals.
|
||||
escape_js_close_tags "$js_raw" "$js_temp"
|
||||
|
||||
compute_build_label "classifier" "${1:-}" "${2:-}"
|
||||
|
||||
# Process template: inject CSS/JS, substitute build label, strip CDN refs.
|
||||
awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" '
|
||||
/\{\{CSS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < css_file) > 0) print line
|
||||
close(css_file)
|
||||
next
|
||||
}
|
||||
/\{\{JS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < js_file) > 0) print line
|
||||
close(js_file)
|
||||
next
|
||||
}
|
||||
/\{\{BUILD_LABEL\}\}/ {
|
||||
if (is_red == "1") {
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
|
||||
} else {
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
|
||||
}
|
||||
print
|
||||
next
|
||||
}
|
||||
/<script src="https?:\/\// { next }
|
||||
/<link rel="stylesheet" href="https?:\/\// { next }
|
||||
{ print }
|
||||
' "$src_html" > "$output_html"
|
||||
|
||||
echo "Wrote $output_html"
|
||||
|
||||
if [ "$is_release" = "1" ]; then
|
||||
promote_release "classifier"
|
||||
fi
|
||||
65
classifier/css/base.css
Normal file
65
classifier/css/base.css
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/* 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; }
|
||||
}
|
||||
319
classifier/css/layout.css
Normal file
319
classifier/css/layout.css
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
/* 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;
|
||||
}
|
||||
463
classifier/css/spreadsheet.css
Normal file
463
classifier/css/spreadsheet.css
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
449
classifier/js/app.js
Normal file
449
classifier/js/app.js
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
})();
|
||||
134
classifier/js/excel.js
Normal file
134
classifier/js/excel.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
})();
|
||||
64
classifier/js/filter.js
Normal file
64
classifier/js/filter.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
})();
|
||||
492
classifier/js/preview.js
Normal file
492
classifier/js/preview.js
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
})();
|
||||
70
classifier/js/resize.js
Normal file
70
classifier/js/resize.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
})();
|
||||
436
classifier/js/scanner.js
Normal file
436
classifier/js/scanner.js
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
})();
|
||||
|
||||
715
classifier/js/selection.js
Normal file
715
classifier/js/selection.js
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
})();
|
||||
215
classifier/js/sort.js
Normal file
215
classifier/js/sort.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
})();
|
||||
942
classifier/js/spreadsheet.js
Normal file
942
classifier/js/spreadsheet.js
Normal file
|
|
@ -0,0 +1,942 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
})();
|
||||
451
classifier/js/store.js
Normal file
451
classifier/js/store.js
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
})();
|
||||
520
classifier/js/tree.js
Normal file
520
classifier/js/tree.js
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
})();
|
||||
78
classifier/js/utils.js
Normal file
78
classifier/js/utils.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* 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,
|
||||
};
|
||||
})();
|
||||
48
classifier/js/validator.js
Normal file
48
classifier/js/validator.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
})();
|
||||
237
classifier/template.html
Normal file
237
classifier/template.html
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
<!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>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<style>
|
||||
{{CSS_PLACEHOLDER}}
|
||||
</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">{{BUILD_LABEL}}</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>
|
||||
{{JS_PLACEHOLDER}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
438
dev-server
Executable file
438
dev-server
Executable file
|
|
@ -0,0 +1,438 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Development HTTP server with cache-busting headers
|
||||
Supports start, status, and stop commands for process management.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import signal
|
||||
import socketserver
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from http.server import SimpleHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
|
||||
class NoCacheHTTPRequestHandler(SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
self.send_header('Pragma', 'no-cache')
|
||||
self.send_header('Expires', '0')
|
||||
super().end_headers()
|
||||
|
||||
class DevServer:
|
||||
def __init__(self, port=8000, directory=None):
|
||||
self.port = port
|
||||
self.directory = directory or os.getcwd()
|
||||
self.runtime_dir = self._get_runtime_dir()
|
||||
# Port-specific PID files to allow multiple servers
|
||||
self.pidfile = self.runtime_dir / f"zddc-dev-server-{port}.pid"
|
||||
|
||||
def _get_runtime_dir(self):
|
||||
"""Get appropriate runtime directory for PID files"""
|
||||
# Try user runtime directory first (systemd)
|
||||
import getpass
|
||||
uid = os.getuid()
|
||||
user_runtime = Path(f"/run/user/{uid}")
|
||||
if user_runtime.exists() and user_runtime.is_dir():
|
||||
runtime_dir = user_runtime / "zddc"
|
||||
runtime_dir.mkdir(exist_ok=True)
|
||||
return runtime_dir
|
||||
|
||||
# Fall back to user's cache directory
|
||||
home = Path.home()
|
||||
cache_dir = home / ".cache" / "zddc"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
def get_server_pid(self):
|
||||
"""Get the PID of running server from pidfile"""
|
||||
pid, _ = self._get_server_info()
|
||||
return pid
|
||||
|
||||
def _get_server_info(self):
|
||||
"""Get PID and serving directory from pidfile"""
|
||||
if not self.pidfile.exists():
|
||||
return None, None
|
||||
|
||||
try:
|
||||
with open(self.pidfile, 'r') as f:
|
||||
content = f.read().strip()
|
||||
|
||||
# Handle different formats: PID, PID:PORT, PID:DIRECTORY
|
||||
if ':' in content:
|
||||
pid_str, rest = content.split(':', 1)
|
||||
pid = int(pid_str)
|
||||
# If rest is numeric, it's old PID:PORT format
|
||||
try:
|
||||
int(rest)
|
||||
serving_dir = None # Old format, directory unknown
|
||||
except ValueError:
|
||||
serving_dir = rest # New PID:DIRECTORY format
|
||||
else:
|
||||
pid = int(content)
|
||||
serving_dir = None # Old format, directory unknown
|
||||
|
||||
# Check if process is actually running using /proc
|
||||
if self._is_process_running(pid):
|
||||
return pid, serving_dir
|
||||
else:
|
||||
# Clean up stale pidfile
|
||||
print(f" Cleaning up stale PID file for process {pid}")
|
||||
self._cleanup()
|
||||
return None, None
|
||||
|
||||
except (ValueError, IOError) as e:
|
||||
print(f" Error reading PID file: {e}")
|
||||
self._cleanup()
|
||||
return None, None
|
||||
|
||||
def _is_process_running(self, pid):
|
||||
"""Check if a process with given PID is running and is our process"""
|
||||
try:
|
||||
# Check if process exists
|
||||
with open(f"/proc/{pid}/comm", 'r') as f:
|
||||
comm = f.read().strip()
|
||||
|
||||
# Verify it's a python process
|
||||
if 'python' not in comm:
|
||||
return False
|
||||
|
||||
# Check command line to verify it's our dev-server
|
||||
try:
|
||||
with open(f"/proc/{pid}/cmdline", 'r') as f:
|
||||
cmdline = f.read()
|
||||
return 'dev-server.py' in cmdline
|
||||
except (FileNotFoundError, PermissionError):
|
||||
# If we can't read cmdline, assume it's our process if it's python
|
||||
return True
|
||||
|
||||
except (FileNotFoundError, PermissionError):
|
||||
return False
|
||||
|
||||
def is_server_running(self):
|
||||
"""Check if server is running by making HTTP request"""
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://localhost:{self.port}/", timeout=2) as response:
|
||||
return response.status == 200 or response.status == 403 # 403 for directory listing disabled
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, OSError):
|
||||
return False
|
||||
|
||||
def start(self, daemon=False):
|
||||
"""Start the development server"""
|
||||
# Check if server is already running on this port
|
||||
existing_pid = self.get_server_pid()
|
||||
if existing_pid:
|
||||
print(f"Server is already running (PID: {existing_pid}) at http://localhost:{self.port}")
|
||||
return True # Exit without error as requested
|
||||
|
||||
# Check if port is in use by another process
|
||||
if self.is_server_running():
|
||||
print(f"Port {self.port} is already in use by another process")
|
||||
return False
|
||||
|
||||
if daemon:
|
||||
self._start_daemon()
|
||||
else:
|
||||
self._start_foreground()
|
||||
|
||||
return True
|
||||
|
||||
def _start_foreground(self):
|
||||
"""Start server in foreground mode"""
|
||||
print(f"Starting dev server on port {self.port}...")
|
||||
print(f"Serving directory: {self.directory}")
|
||||
print("Press Ctrl+C to stop")
|
||||
|
||||
# Change to the specified directory
|
||||
try:
|
||||
os.chdir(self.directory)
|
||||
except OSError as e:
|
||||
print(f"Failed to change to directory {self.directory}: {e}")
|
||||
return False
|
||||
|
||||
# Set up signal handler for graceful shutdown
|
||||
def signal_handler(signum, frame):
|
||||
print("\nShutting down server...")
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
try:
|
||||
httpd = socketserver.TCPServer(("", self.port), NoCacheHTTPRequestHandler)
|
||||
httpd.serve_forever()
|
||||
except OSError as e:
|
||||
if e.errno == 98: # Address already in use
|
||||
print(f"Port {self.port} is already in use")
|
||||
else:
|
||||
print(f"Error starting server: {e}")
|
||||
return False
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error starting server: {e}")
|
||||
finally:
|
||||
self._cleanup()
|
||||
|
||||
def _start_daemon(self):
|
||||
"""Start server in daemon mode (background)"""
|
||||
try:
|
||||
# Fork the first time (detach from parent)
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
# Parent process - wait a moment then exit
|
||||
time.sleep(0.5)
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f"Fork #1 failed: {e}")
|
||||
return False
|
||||
|
||||
# Change to the specified directory before daemonizing
|
||||
try:
|
||||
os.chdir(self.directory)
|
||||
except OSError as e:
|
||||
print(f"Failed to change to directory {self.directory}: {e}")
|
||||
return False
|
||||
|
||||
# Decouple from parent environment
|
||||
os.setsid()
|
||||
os.umask(0)
|
||||
|
||||
# Fork the second time (prevent zombie processes)
|
||||
try:
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
# Parent process - exit immediately
|
||||
os._exit(0)
|
||||
except OSError as e:
|
||||
print(f"Fork #2 failed: {e}")
|
||||
os._exit(1)
|
||||
|
||||
# Redirect standard file descriptors
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
|
||||
with open('/dev/null', 'r') as si:
|
||||
os.dup2(si.fileno(), sys.stdin.fileno())
|
||||
with open('/dev/null', 'w') as so:
|
||||
os.dup2(so.fileno(), sys.stdout.fileno())
|
||||
with open('/dev/null', 'w') as se:
|
||||
os.dup2(se.fileno(), sys.stderr.fileno())
|
||||
|
||||
# Write PID to file with directory information
|
||||
try:
|
||||
with open(self.pidfile, 'w') as f:
|
||||
f.write(f"{os.getpid()}:{self.directory}")
|
||||
print(f"Dev server started on port {self.port} serving {self.directory}")
|
||||
return True
|
||||
except IOError as e:
|
||||
print(f"Failed to write PID file: {e}")
|
||||
return False
|
||||
|
||||
# Start the server
|
||||
try:
|
||||
with socketserver.TCPServer(("", self.port), NoCacheHTTPRequestHandler) as httpd:
|
||||
httpd.serve_forever()
|
||||
except Exception:
|
||||
# In daemon mode, errors just cause the process to exit
|
||||
pass
|
||||
finally:
|
||||
self._cleanup()
|
||||
|
||||
def status(self):
|
||||
"""Check server status by testing HTTP connection"""
|
||||
# Check for stale PID files first
|
||||
self._cleanup_if_stale()
|
||||
|
||||
pid, serving_dir = self._get_server_info()
|
||||
if pid:
|
||||
# Test if the server is actually responding on its port
|
||||
server_responding = self.is_server_running()
|
||||
|
||||
print(f"Dev server is running")
|
||||
print(f" PID: {pid}")
|
||||
print(f" Port: {self.port}")
|
||||
print(f" URL: http://localhost:{self.port}")
|
||||
print(f" Directory: {serving_dir or 'Unknown (old PID file format)'}")
|
||||
print(f" PID file: {self.pidfile}")
|
||||
|
||||
if server_responding:
|
||||
print(f" Status: Responding to HTTP requests")
|
||||
else:
|
||||
print(f" Status: Process running but not responding to HTTP (may be starting up)")
|
||||
|
||||
# Get process uptime
|
||||
try:
|
||||
uptime_seconds = self._get_process_uptime(pid)
|
||||
print(f" Uptime: {self._format_uptime(uptime_seconds)}")
|
||||
except (FileNotFoundError, IndexError, ValueError):
|
||||
print(f" Uptime: Unable to determine")
|
||||
|
||||
return True
|
||||
else:
|
||||
print("Dev server is not running")
|
||||
return False
|
||||
|
||||
def _get_process_uptime(self, pid):
|
||||
"""Get the actual uptime of a process in seconds"""
|
||||
with open(f"/proc/{pid}/stat", 'r') as f:
|
||||
stat_data = f.read().split()
|
||||
starttime_ticks = int(stat_data[21]) # Process start time in ticks since boot
|
||||
|
||||
# Get system clock ticks per second
|
||||
clock_ticks = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
|
||||
|
||||
# Get system boot time
|
||||
with open("/proc/stat", 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('btime '):
|
||||
boot_time = int(line.split()[1])
|
||||
break
|
||||
|
||||
# Calculate process start time in seconds since epoch
|
||||
process_start_time = boot_time + (starttime_ticks / clock_ticks)
|
||||
|
||||
# Calculate uptime
|
||||
return time.time() - process_start_time
|
||||
|
||||
|
||||
|
||||
def _cleanup_if_stale(self):
|
||||
"""Check for and clean up stale PID files"""
|
||||
if self.pidfile.exists():
|
||||
try:
|
||||
with open(self.pidfile, 'r') as f:
|
||||
content = f.read().strip()
|
||||
|
||||
# Handle different formats: PID, PID:PORT, PID:DIRECTORY
|
||||
if ':' in content:
|
||||
pid = int(content.split(':', 1)[0])
|
||||
else:
|
||||
pid = int(content)
|
||||
|
||||
if not self._is_process_running(pid):
|
||||
print(f" Found stale PID file for process {pid}, cleaning up")
|
||||
self._cleanup()
|
||||
except (ValueError, IOError):
|
||||
print(f" Found corrupted PID file, cleaning up")
|
||||
self._cleanup()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the development server"""
|
||||
pid = self.get_server_pid()
|
||||
if not pid:
|
||||
if self.is_server_running():
|
||||
print("Server is running but not managed by this script")
|
||||
print("Cannot stop server started by another process")
|
||||
return False
|
||||
else:
|
||||
print("Dev server is not running")
|
||||
return False
|
||||
|
||||
try:
|
||||
print(f"Stopping dev server (PID: {pid})...")
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
|
||||
# Wait for process to stop
|
||||
for i in range(30): # Wait up to 3 seconds
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
os.kill(pid, 0) # Test if process still exists
|
||||
except OSError:
|
||||
# Process no longer exists
|
||||
break
|
||||
else:
|
||||
# Force kill if still running
|
||||
print("Process didn't stop gracefully, force killing...")
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass # Process might have died already
|
||||
|
||||
self._cleanup()
|
||||
print("Dev server stopped")
|
||||
return True
|
||||
|
||||
except OSError as e:
|
||||
print(f"Error stopping server: {e}")
|
||||
self._cleanup()
|
||||
return False
|
||||
|
||||
def _cleanup(self):
|
||||
"""Clean up PID file"""
|
||||
if self.pidfile.exists():
|
||||
try:
|
||||
self.pidfile.unlink()
|
||||
except OSError as e:
|
||||
print(f"Warning: Could not remove PID file {self.pidfile}: {e}")
|
||||
|
||||
def _format_uptime(self, seconds):
|
||||
"""Format uptime in human readable format"""
|
||||
if seconds < 60:
|
||||
return f"{int(seconds)}s"
|
||||
elif seconds < 3600:
|
||||
return f"{int(seconds // 60)}m {int(seconds % 60)}s"
|
||||
else:
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
return f"{hours}h {minutes}m"
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ZDDC Development Server",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""Commands:
|
||||
start Start the development server (default)
|
||||
status Show server status
|
||||
stop Stop the development server
|
||||
|
||||
Examples:
|
||||
%(prog)s # Start server in foreground (default)
|
||||
%(prog)s start -d # Start server in background
|
||||
%(prog)s status # Check if server is running
|
||||
%(prog)s stop # Stop the server
|
||||
%(prog)s -p 8080 start # Start on port 8080
|
||||
%(prog)s start ~/docs # Start serving ~/docs directory
|
||||
%(prog)s -p 9000 ~/src # Start serving ~/src on port 9000"""
|
||||
)
|
||||
|
||||
parser.add_argument('command', nargs='?', default='start',
|
||||
choices=['start', 'status', 'stop'],
|
||||
help='Command to execute (default: start)')
|
||||
parser.add_argument('-p', '--port', type=int, default=8000,
|
||||
help='Port to run server on (default: 8000)')
|
||||
parser.add_argument('-d', '--daemon', action='store_true',
|
||||
help='Run server in background (daemon mode)')
|
||||
parser.add_argument('directory', nargs='?', default=None,
|
||||
help='Directory to serve (default: current directory)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
server = DevServer(port=args.port, directory=args.directory)
|
||||
|
||||
if args.command == 'start':
|
||||
if not server.start(daemon=args.daemon):
|
||||
sys.exit(1)
|
||||
# After starting (or if already running), show status
|
||||
if not server.status():
|
||||
sys.exit(1)
|
||||
elif args.command == 'status':
|
||||
if not server.status():
|
||||
sys.exit(1)
|
||||
elif args.command == 'stop':
|
||||
if not server.stop():
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nOperation cancelled")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
72
landing/build.sh
Executable file
72
landing/build.sh
Executable file
|
|
@ -0,0 +1,72 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
root_dir=$(cd "$(dirname "$0")" && pwd)
|
||||
. "$root_dir/../shared/build-lib.sh"
|
||||
|
||||
src_html="$root_dir/template.html"
|
||||
output_dir="$root_dir/dist"
|
||||
output_html="$output_dir/index.html"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
ensure_exists "$src_html"
|
||||
|
||||
css_temp=$(mktemp)
|
||||
js_raw=$(mktemp)
|
||||
js_temp=$(mktemp)
|
||||
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
concat_files \
|
||||
"../shared/base.css" \
|
||||
"css/landing.css" \
|
||||
> "$css_temp"
|
||||
|
||||
concat_files \
|
||||
"../shared/theme.js" \
|
||||
"js/landing.js" \
|
||||
> "$js_raw"
|
||||
|
||||
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
||||
# for a closing </script> tag.
|
||||
escape_js_close_tags "$js_raw" "$js_temp"
|
||||
|
||||
compute_build_label "landing" "${1:-}" "${2:-}"
|
||||
|
||||
# Process template: inject CSS/JS, substitute build label, strip CDN refs
|
||||
awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" '
|
||||
/\{\{CSS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < css_file) > 0) print line
|
||||
close(css_file)
|
||||
next
|
||||
}
|
||||
/\{\{JS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < js_file) > 0) print line
|
||||
close(js_file)
|
||||
next
|
||||
}
|
||||
/\{\{BUILD_LABEL\}\}/ {
|
||||
if (is_red == "1") {
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
|
||||
} else {
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
|
||||
}
|
||||
print
|
||||
next
|
||||
}
|
||||
/<script src="https?:\/\// { next }
|
||||
/<link rel="stylesheet" href="https?:\/\// { next }
|
||||
{ print }
|
||||
' "$src_html" > "$output_html"
|
||||
|
||||
echo "Wrote $output_html"
|
||||
|
||||
if [ "$is_release" = "1" ]; then
|
||||
promote_release "landing"
|
||||
# NOTE: website/index.html is a hand-edited intro page for
|
||||
# zddc.varasys.io, not the landing tool. The landing tool ships
|
||||
# only via website/releases/ and install.zip — install.zip puts
|
||||
# landing_latest.html at the customer deployment root, where the
|
||||
# project picker UI is useful (it queries zddc-server for the
|
||||
# project list). See AGENTS.md "Releasing — channels and layout".
|
||||
fi
|
||||
180
landing/css/landing.css
Normal file
180
landing/css/landing.css
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/* Landing page layout */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.landing-main {
|
||||
max-width: 640px;
|
||||
margin: 32px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
/* Access warning banner */
|
||||
.access-warning-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: var(--radius);
|
||||
color: #664d03;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.access-warning-banner.hidden { display: none; }
|
||||
.warning-dismiss-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #664d03;
|
||||
font-size: 1rem;
|
||||
padding: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Main card */
|
||||
.landing-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.landing-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.landing-card-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.landing-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Project list */
|
||||
.project-list {
|
||||
padding: 8px 0;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.project-list-empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.project-item:hover { background: var(--bg-hover); }
|
||||
.project-item input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
.project-item-name {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.landing-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Preset menu */
|
||||
.preset-control {
|
||||
position: relative;
|
||||
}
|
||||
.preset-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
||||
min-width: 200px;
|
||||
z-index: 100;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.preset-menu.hidden { display: none; }
|
||||
.preset-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
gap: 8px;
|
||||
}
|
||||
.preset-menu-item:hover { background: var(--bg-hover); }
|
||||
.preset-menu-item-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.preset-delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
padding: 0 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.preset-delete-btn:hover { color: var(--danger); }
|
||||
.preset-menu-empty {
|
||||
padding: 8px 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.preset-menu-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.project-list-loading {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
215
landing/js/landing.js
Normal file
215
landing/js/landing.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
|
||||
var accessibleProjects = []; // [{name, url}, ...] from server
|
||||
var presets = []; // [{name, projects[]}, ...] from localStorage
|
||||
var PRESETS_KEY = 'zddc_landing_presets';
|
||||
|
||||
// ── Initialise ──────────────────────────────────────────────────────────
|
||||
|
||||
async function init() {
|
||||
loadPresets();
|
||||
|
||||
var urlProjects = getUrlProjects();
|
||||
|
||||
var projectList = document.getElementById('projectList');
|
||||
projectList.innerHTML = '<div class="project-list-loading">Loading projects…<\/div>';
|
||||
|
||||
try {
|
||||
var resp = await fetch(location.origin + location.pathname.replace(/\/[^\/]*$/, '/'), {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
accessibleProjects = await resp.json();
|
||||
} catch (e) {
|
||||
projectList.innerHTML = '<div class="project-list-empty">Could not load project list: ' + escapeHtml(e.message) + '<\/div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn about URL projects that are not accessible
|
||||
if (urlProjects.size > 0) {
|
||||
var missing = Array.from(urlProjects).filter(function(p) {
|
||||
return !accessibleProjects.some(function(ap) { return ap.name === p; });
|
||||
});
|
||||
if (missing.length > 0) {
|
||||
showWarning('This link includes projects you don\'t have access to: ' + missing.map(escapeHtml).join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
renderProjects(urlProjects);
|
||||
renderPresetMenu();
|
||||
|
||||
// Close preset menu on outside click
|
||||
document.addEventListener('click', function(e) {
|
||||
var menu = document.getElementById('presetMenu');
|
||||
var btn = document.getElementById('presetMenuBtn');
|
||||
if (menu && !menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getUrlProjects() {
|
||||
var params = new URLSearchParams(location.search);
|
||||
var val = params.get('projects');
|
||||
if (!val) return new Set();
|
||||
return new Set(val.split(',').map(function(p) { return p.trim(); }).filter(Boolean));
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
function renderProjects(preCheck) {
|
||||
var container = document.getElementById('projectList');
|
||||
if (accessibleProjects.length === 0) {
|
||||
container.innerHTML = '<div class="project-list-empty">No projects available.<\/div>';
|
||||
return;
|
||||
}
|
||||
var html = accessibleProjects.map(function(p) {
|
||||
var checked = (preCheck.size === 0 || preCheck.has(p.name)) ? ' checked' : '';
|
||||
return '<div class="project-item" onclick="LandingApp.toggleProject(this)">' +
|
||||
'<input type="checkbox" value="' + escapeHtml(p.name) + '"' + checked + ' onclick="event.stopPropagation()">' +
|
||||
'<span class="project-item-name">' + escapeHtml(p.name) + '<\/span>' +
|
||||
'<\/div>';
|
||||
}).join('');
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderPresetMenu() {
|
||||
var menu = document.getElementById('presetMenu');
|
||||
if (!menu) return;
|
||||
if (presets.length === 0) {
|
||||
menu.innerHTML = '<div class="preset-menu-empty">No presets saved.<\/div>';
|
||||
return;
|
||||
}
|
||||
menu.innerHTML = presets.map(function(preset) {
|
||||
return '<div class="preset-menu-item">' +
|
||||
'<span class="preset-menu-item-name" onclick="LandingApp.applyPreset(' + JSON.stringify(preset.name) + ')">' +
|
||||
escapeHtml(preset.name) + '<\/span>' +
|
||||
'<button class="preset-delete-btn" onclick="LandingApp.deletePreset(' + JSON.stringify(preset.name) + ')" title="Delete preset">×<\/button>' +
|
||||
'<\/div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function selectAll() {
|
||||
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
|
||||
cb.checked = true;
|
||||
});
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
|
||||
cb.checked = false;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleProject(row) {
|
||||
var cb = row.querySelector('input[type="checkbox"]');
|
||||
if (cb) cb.checked = !cb.checked;
|
||||
}
|
||||
|
||||
function openArchive() {
|
||||
var checked = Array.from(document.querySelectorAll('#projectList input[type="checkbox"]:checked'))
|
||||
.map(function(cb) { return cb.value; });
|
||||
if (checked.length === 0) {
|
||||
alert('Select at least one project to open.');
|
||||
return;
|
||||
}
|
||||
var base = location.pathname.replace(/\/[^\/]*$/, '/');
|
||||
location.href = base + 'archive.html?projects=' + checked.map(encodeURIComponent).join(',');
|
||||
}
|
||||
|
||||
function savePreset() {
|
||||
var name = prompt('Enter a name for this preset:');
|
||||
if (!name || !name.trim()) return;
|
||||
name = name.trim();
|
||||
var checked = Array.from(document.querySelectorAll('#projectList input[type="checkbox"]:checked'))
|
||||
.map(function(cb) { return cb.value; });
|
||||
// Replace existing preset with same name
|
||||
presets = presets.filter(function(p) { return p.name !== name; });
|
||||
presets.push({ name: name, projects: checked });
|
||||
savePresets();
|
||||
renderPresetMenu();
|
||||
}
|
||||
|
||||
function togglePresetMenu() {
|
||||
var menu = document.getElementById('presetMenu');
|
||||
if (menu) menu.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function applyPreset(name) {
|
||||
var preset = presets.find(function(p) { return p.name === name; });
|
||||
if (!preset) return;
|
||||
var projectSet = new Set(preset.projects);
|
||||
document.querySelectorAll('#projectList input[type="checkbox"]').forEach(function(cb) {
|
||||
cb.checked = projectSet.has(cb.value);
|
||||
});
|
||||
document.getElementById('presetMenu').classList.add('hidden');
|
||||
}
|
||||
|
||||
function deletePreset(name) {
|
||||
presets = presets.filter(function(p) { return p.name !== name; });
|
||||
savePresets();
|
||||
renderPresetMenu();
|
||||
}
|
||||
|
||||
function dismissWarning() {
|
||||
var el = document.getElementById('accessWarningBanner');
|
||||
if (el) el.classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Warning ───────────────────────────────────────────────────────────────
|
||||
|
||||
function showWarning(message) {
|
||||
var el = document.getElementById('accessWarningBanner');
|
||||
var txt = document.getElementById('accessWarningText');
|
||||
if (!el || !txt) return;
|
||||
txt.textContent = message;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────
|
||||
|
||||
function loadPresets() {
|
||||
try {
|
||||
var raw = localStorage.getItem(PRESETS_KEY);
|
||||
presets = raw ? JSON.parse(raw) : [];
|
||||
if (!Array.isArray(presets)) presets = [];
|
||||
} catch (e) {
|
||||
presets = [];
|
||||
}
|
||||
}
|
||||
|
||||
function savePresets() {
|
||||
try {
|
||||
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
|
||||
} catch (e) { /* quota exceeded or private browsing */ }
|
||||
}
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
window.LandingApp = {
|
||||
init: init,
|
||||
selectAll: selectAll,
|
||||
selectNone: selectNone,
|
||||
toggleProject: toggleProject,
|
||||
openArchive: openArchive,
|
||||
savePreset: savePreset,
|
||||
togglePresetMenu: togglePresetMenu,
|
||||
applyPreset: applyPreset,
|
||||
deletePreset: deletePreset,
|
||||
dismissWarning: dismissWarning
|
||||
};
|
||||
|
||||
})();
|
||||
58
landing/template.html
Normal file
58
landing/template.html
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Archive — Projects</title>
|
||||
<style>
|
||||
{{CSS_PLACEHOLDER}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="landing-main">
|
||||
<!-- Access warning banner (shown when URL ?projects= contains inaccessible items) -->
|
||||
<div id="accessWarningBanner" class="access-warning-banner hidden" role="alert">
|
||||
<span id="accessWarningText"></span>
|
||||
<button class="warning-dismiss-btn" onclick="LandingApp.dismissWarning()" aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
|
||||
<div class="landing-card">
|
||||
<div class="landing-card-header">
|
||||
<h2>Select Projects</h2>
|
||||
<div class="landing-header-actions">
|
||||
<!-- Presets dropdown -->
|
||||
<div class="preset-control">
|
||||
<button id="presetMenuBtn" class="btn btn-secondary btn-sm" onclick="LandingApp.togglePresetMenu()">▾ Presets</button>
|
||||
<div id="presetMenu" class="preset-menu hidden"></div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="LandingApp.selectAll()">Select All</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="LandingApp.selectNone()">Select None</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="projectList" class="project-list">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
|
||||
<div class="landing-card-footer">
|
||||
<button id="savePresetBtn" class="btn btn-secondary" onclick="LandingApp.savePreset()">Save Preset…</button>
|
||||
<button id="openArchiveBtn" class="btn btn-primary" onclick="LandingApp.openArchive()">Open Archive →</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
{{JS_PLACEHOLDER}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
131
mdedit/README.md
Normal file
131
mdedit/README.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# ZDDC Markdown Editor
|
||||
|
||||
[← Back to ZDDC](../README.md)
|
||||
|
||||
A lightweight, browser-based markdown editor with YAML front matter support.
|
||||
|
||||
**[🔗 Open Markdown Editor](dist/mdedit.html)** - Click to use online, or right-click → "Save Link As" to keep your own copy.
|
||||
|
||||
## Reliability
|
||||
|
||||
This tool follows the "record player with the record" philosophy - the application and your data travel together. The single HTML file contains everything needed to edit markdown files locally in your browser.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Open the editor in your browser
|
||||
2. Click **Select Directory** to choose a folder with markdown files
|
||||
3. Navigate the file tree on the left
|
||||
4. Click any `.md` file to edit it
|
||||
5. Click **Save File** or **Save All** to save changes
|
||||
|
||||
## Features
|
||||
|
||||
### 📂 File Navigation
|
||||
- Browse directories using the File System Access API
|
||||
- Collapsible folder tree with file type icons
|
||||
- Files sorted alphabetically with directories grouped
|
||||
|
||||
### ✏️ Markdown Editing
|
||||
- Toast UI Editor with live preview
|
||||
- Split view (markdown + preview)
|
||||
- Full toolbar for formatting
|
||||
|
||||
### 📋 YAML Front Matter
|
||||
- Separate front matter section at top of editor
|
||||
- Auto-parsed and preserved on save
|
||||
- Collapsible for more editing space
|
||||
|
||||
### 📑 Table of Contents
|
||||
- Auto-generated from headings
|
||||
- Adjustable depth (H1 only through H6)
|
||||
- Click to jump to heading in preview
|
||||
|
||||
### 💾 File Operations
|
||||
- Save individual files or Save All
|
||||
- Reload from disk (discards unsaved changes)
|
||||
- External change detection with reload prompt
|
||||
- Unsaved change warnings before leaving
|
||||
|
||||
### 🖼️ File Previews
|
||||
- Image preview for common formats
|
||||
- HTML preview in sandboxed iframe
|
||||
- Plain text editing for non-markdown files
|
||||
|
||||
## Build
|
||||
|
||||
The editor is built from modular source files using a bash script:
|
||||
|
||||
```bash
|
||||
cd mdedit
|
||||
./build.sh
|
||||
```
|
||||
|
||||
This concatenates CSS and JS files into `dist/mdedit.html`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mdedit/
|
||||
├── css/
|
||||
│ ├── base.css # Core styles and layout
|
||||
│ ├── editor.css # Toast UI Editor overrides
|
||||
│ ├── toc.css # Table of Contents styles
|
||||
│ └── markdown.css # Markdown rendering styles
|
||||
├── js/
|
||||
│ ├── app.js # Global state
|
||||
│ ├── utils.js # Utility functions
|
||||
│ ├── front-matter.js # YAML parsing
|
||||
│ ├── file-system.js # File operations
|
||||
│ ├── file-tree.js # Tree rendering
|
||||
│ ├── editor.js # Toast UI setup
|
||||
│ ├── toc.js # TOC generation
|
||||
│ ├── resizer.js # Pane resizing
|
||||
│ ├── events.js # Event listeners
|
||||
│ └── main.js # Initialization
|
||||
├── vendor/
|
||||
│ ├── toastui-editor-all.min.js # Toast UI Editor JS (bundled)
|
||||
│ └── toastui-editor.min.css # Toast UI Editor CSS (bundled)
|
||||
├── template.html # HTML structure (uses CDN for local dev convenience)
|
||||
├── build.sh # Build script (inlines vendor files, strips CDN refs)
|
||||
└── dist/
|
||||
└── mdedit.html # Built self-contained file
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
- **No server required** - runs entirely in browser
|
||||
- **File System Access API** - direct local file access
|
||||
- **Toast UI Editor v3.2.2** - bundled from `vendor/` into the built output (no CDN required)
|
||||
- **Tailwind CSS** - replaced at build time by `css/tailwind-utils.css`, a hand-written static subset containing only the ~80 utility classes actually used in `template.html` (no runtime overhead, no console warnings)
|
||||
- **Fully self-contained** - `dist/mdedit.html` (~850 KB) works offline with no external dependencies
|
||||
|
||||
> **Development note**: `template.html` loads Toast UI and Tailwind from CDN for a faster local development
|
||||
> experience (open `template.html` directly in a browser). The `build.sh` script replaces the Tailwind CDN
|
||||
> `<script>` tag with nothing (utilities come from `css/tailwind-utils.css` instead) and replaces the Toast UI
|
||||
> CDN tags with the locally bundled `vendor/` files when producing `dist/mdedit.html`.
|
||||
|
||||
### Modules
|
||||
|
||||
CSS and JS modules live under `css/` and `js/`. The canonical load order is in `build.sh`. See the root `ARCHITECTURE.md` for the build/module pattern and `AGENTS.md` for shared helpers.
|
||||
|
||||
mdedit-specific notes:
|
||||
- `css/tailwind-utils.css` is a hand-curated static subset of Tailwind v3 — there is no Tailwind build step. Add a class here when adding it to `template.html`.
|
||||
- Toast UI Editor v3.2.2 ships pre-bundled in `vendor/`. `template.html` loads it from CDN for dev convenience; `build.sh` swaps the CDN tag for the bundled file.
|
||||
- File operations (create, rename, delete) live in `js/file-ops.js`.
|
||||
|
||||
### Build Process
|
||||
|
||||
The build script (`build.sh`):
|
||||
1. Concatenates all local CSS and JS files in dependency order
|
||||
2. **Replaces** the CDN `<script>`/`<link>` tags for Tailwind and Toast UI with the locally bundled files from `vendor/`
|
||||
3. Injects everything into `template.html` to produce `dist/mdedit.html`
|
||||
|
||||
The final HTML file (~850 KB) is fully self-contained and works offline.
|
||||
|
||||
### Architecture Notes
|
||||
|
||||
- All local CSS/JS files are inlined into the output HTML
|
||||
- Vendor dependencies (Toast UI, Tailwind) are bundled from `vendor/` — no runtime CDN access
|
||||
- `template.html` loads dependencies from CDN for convenient local development, but `build.sh` replaces these
|
||||
- No npm dependencies required at runtime
|
||||
- File System Access API requires Chromium-based browsers
|
||||
127
mdedit/build.sh
Normal file
127
mdedit/build.sh
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
root_dir=$(cd "$(dirname "$0")" && pwd)
|
||||
. "$root_dir/../shared/build-lib.sh"
|
||||
|
||||
src_html="$root_dir/template.html"
|
||||
output_dir="$root_dir/dist"
|
||||
output_html="$output_dir/mdedit.html"
|
||||
|
||||
# Vendor files (bundled dependencies — no CDN required at runtime)
|
||||
# Note: Tailwind is NOT a vendor file — it's replaced by css/tailwind-utils.css,
|
||||
# a hand-written subset of only the utility classes used in template.html.
|
||||
toastui_js="$root_dir/vendor/toastui-editor-all.min.js"
|
||||
toastui_css="$root_dir/vendor/toastui-editor.min.css"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
ensure_exists "$src_html"
|
||||
ensure_exists "$toastui_js"
|
||||
ensure_exists "$toastui_css"
|
||||
|
||||
css_temp=$(mktemp)
|
||||
js_raw=$(mktemp)
|
||||
js_temp=$(mktemp)
|
||||
toastui_js_safe=$(mktemp)
|
||||
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp" "$toastui_js_safe"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# CSS files to concatenate in order
|
||||
concat_files \
|
||||
"css/tailwind-utils.css" \
|
||||
"../shared/base.css" \
|
||||
"css/base.css" \
|
||||
"css/editor.css" \
|
||||
"css/toc.css" \
|
||||
"css/markdown.css" \
|
||||
> "$css_temp"
|
||||
|
||||
# JavaScript files to concatenate in order
|
||||
concat_files \
|
||||
"../shared/zddc.js" \
|
||||
"../shared/theme.js" \
|
||||
"js/app.js" \
|
||||
"js/utils.js" \
|
||||
"js/front-matter.js" \
|
||||
"js/file-ops.js" \
|
||||
"js/file-system.js" \
|
||||
"js/file-tree.js" \
|
||||
"js/editor.js" \
|
||||
"js/toc.js" \
|
||||
"js/resizer.js" \
|
||||
"js/events.js" \
|
||||
"js/main.js" \
|
||||
"../shared/help.js" \
|
||||
> "$js_raw"
|
||||
|
||||
# Escape '</' in app JS and the Toast UI vendor JS so neither can prematurely
|
||||
# close the inline <script> blocks they get embedded in.
|
||||
escape_js_close_tags "$js_raw" "$js_temp"
|
||||
escape_js_close_tags "$toastui_js" "$toastui_js_safe"
|
||||
|
||||
compute_build_label "mdedit" "${1:-}" "${2:-}"
|
||||
|
||||
# Process template:
|
||||
# - Strip the Tailwind CDN <script> tag (css/tailwind-utils.css replaces it)
|
||||
# - Replace CDN <link> for Toast UI CSS with inline bundled CSS
|
||||
# - Replace CDN <script src="...toastui..."> with inline bundled Toast UI JS
|
||||
# - Inject custom CSS/JS at {{CSS_PLACEHOLDER}} and {{JS_PLACEHOLDER}}
|
||||
# - Substitute {{BUILD_LABEL}}
|
||||
awk \
|
||||
-v css_file="$css_temp" \
|
||||
-v js_file="$js_temp" \
|
||||
-v toastui_js="$toastui_js_safe" \
|
||||
-v toastui_css="$toastui_css" \
|
||||
-v build_label="$build_label" \
|
||||
-v is_red="$is_red" \
|
||||
'
|
||||
/\{\{CSS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < css_file) > 0) print line
|
||||
close(css_file)
|
||||
next
|
||||
}
|
||||
/\{\{JS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < js_file) > 0) print line
|
||||
close(js_file)
|
||||
next
|
||||
}
|
||||
/\{\{BUILD_LABEL\}\}/ {
|
||||
if (is_red == "1") {
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
|
||||
} else {
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
|
||||
}
|
||||
print
|
||||
next
|
||||
}
|
||||
/<script src="https:\/\/cdn\.tailwindcss\.com"/ {
|
||||
# Stripped: Tailwind utility classes are in css/tailwind-utils.css instead
|
||||
next
|
||||
}
|
||||
/<link rel="stylesheet" href="https:\/\/uicdn\.toast\.com\/editor\/[^"]*\/toastui-editor\.min\.css"/ {
|
||||
# Inline the bundled Toast UI CSS
|
||||
print "<style>"
|
||||
while ((getline line < toastui_css) > 0) print line
|
||||
close(toastui_css)
|
||||
print "</style>"
|
||||
next
|
||||
}
|
||||
/<script src="https:\/\/uicdn\.toast\.com\/editor\/[^"]*\/toastui-editor/ {
|
||||
# Inline the bundled Toast UI JS (already passed through escape_js_close_tags
|
||||
# so its content cannot contain a literal </script> sequence). We close with
|
||||
# the real </script> because only that exact string terminates a script
|
||||
# block per the HTML5 spec.
|
||||
print "<script>"
|
||||
while ((getline line < toastui_js) > 0) print line
|
||||
close(toastui_js)
|
||||
print "</script>"
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$src_html" > "$output_html"
|
||||
|
||||
echo "Wrote $output_html ($(wc -c < "$output_html") bytes)"
|
||||
|
||||
if [ "$is_release" = "1" ]; then
|
||||
promote_release "mdedit"
|
||||
fi
|
||||
382
mdedit/css/base.css
Normal file
382
mdedit/css/base.css
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
/* mdedit component styles — reset and tokens from shared/base.css */
|
||||
|
||||
/* Pane resizer */
|
||||
.pane-resizer:hover {
|
||||
background-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* File tree */
|
||||
.file-tree {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.directory-item,
|
||||
.file-item {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dir-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: rotate(90deg);
|
||||
transition: transform 0.2s ease;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dir-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.directory-item.collapsed .dir-icon {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
/* Two-line filename styles */
|
||||
.filename-main {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.filename-secondary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Active file highlighting */
|
||||
.active-file {
|
||||
background-color: var(--primary) !important;
|
||||
color: var(--text-light) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.active-file * {
|
||||
color: var(--text-light) !important;
|
||||
}
|
||||
|
||||
/* ── File Tree Action Buttons ──────────────────────────────────────────────── */
|
||||
.tree-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.directory-item:hover .tree-actions,
|
||||
.file-item:hover .tree-actions,
|
||||
.active-file .tree-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tree-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.tree-btn:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tree-btn--danger:hover {
|
||||
background-color: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tree-btn--danger:hover {
|
||||
background-color: rgba(127, 29, 29, 0.5);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .tree-btn--danger:hover {
|
||||
background-color: rgba(127, 29, 29, 0.5);
|
||||
color: #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-btn svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Directory toggle indicator */
|
||||
.directory-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.directory-item.collapsed .directory-contents {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* File view container */
|
||||
.file-view-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* File header */
|
||||
.file-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.file-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* File content area */
|
||||
.file-content-area {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Content container */
|
||||
#content-container {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Image preview */
|
||||
.image-preview-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* HTML preview iframe */
|
||||
.html-preview-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.html-preview-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Dirty indicator */
|
||||
.dirty-indicator {
|
||||
margin-left: 0.25rem;
|
||||
color: var(--warning);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.is-dirty {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── App header layout ────────────────────────────────────────────────────── */
|
||||
.header-left,
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Tailwind class overrides: use CSS tokens instead of hardcoded colours ── */
|
||||
/* bg-white / bg-gray-100 are used on the pane backgrounds in template.html. */
|
||||
/* Override them here so they follow the design-token system (light + dark). */
|
||||
.bg-white { background-color: var(--bg) !important; }
|
||||
.bg-gray-100 { background-color: var(--bg-secondary) !important; }
|
||||
|
||||
/* ── Front matter nav bar ──────────────────────────────────────────────────── */
|
||||
.front-matter-nav {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.front-matter-nav__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.front-matter-nav__header:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.front-matter-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: transform 0.2s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.front-matter-toggle:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.front-matter-toggle svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Toggle arrow rotation for collapsed state */
|
||||
.front-matter-nav.collapsed .front-matter-toggle .arrow-down {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Front matter content area */
|
||||
.front-matter-content {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease, padding 0.25s ease, opacity 0.25s ease;
|
||||
max-height: 500px;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* When collapsed, hide content completely */
|
||||
.front-matter-nav.collapsed {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.front-matter-nav.collapsed .front-matter-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Front matter textarea */
|
||||
.front-matter-textarea {
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
border: none;
|
||||
resize: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
white-space: pre; /* preserve yaml structure, enables horiz scroll */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden; /* height is set by JS to fit content exactly */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Hidden utility (for disabled buttons) ─────────────────────────────────── */
|
||||
.hide { display: none; }
|
||||
|
||||
/* ── File tree row layout ───────────────────────────────────────────────────── */
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-row__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── New-file modal ─────────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-overlay.hidden { display: none; }
|
||||
.modal-box {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
min-width: 20rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.18);
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-input:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
25
mdedit/css/editor.css
Normal file
25
mdedit/css/editor.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/* Toast UI Editor styles */
|
||||
#markdown-editor {
|
||||
display: block !important;
|
||||
height: 100% !important;
|
||||
min-height: 500px !important;
|
||||
width: 100% !important;
|
||||
position: relative !important;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.editor-instance {
|
||||
height: 100% !important;
|
||||
min-height: 500px !important;
|
||||
}
|
||||
|
||||
.toastui-editor-defaultUI {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.toastui-editor-defaultUI-toolbar,
|
||||
.toastui-editor-main,
|
||||
.toastui-editor-main .ProseMirror,
|
||||
.toastui-editor-main .toastui-editor-md-preview {
|
||||
height: 100% !important;
|
||||
}
|
||||
223
mdedit/css/markdown.css
Normal file
223
mdedit/css/markdown.css
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/* Markdown content rendering styles */
|
||||
.markdown-content {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.toastui-editor-contents h1 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-content h2,
|
||||
.toastui-editor-contents h2 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-content h3,
|
||||
.toastui-editor-contents h3 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-content h4,
|
||||
.toastui-editor-contents h4 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-content h5,
|
||||
.toastui-editor-contents h5 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 0.875em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-content h6,
|
||||
.toastui-editor-contents h6 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-muted);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Reset margin-top for first-child headings */
|
||||
.markdown-content h1:first-child,
|
||||
.markdown-content h2:first-child,
|
||||
.markdown-content h3:first-child,
|
||||
.markdown-content h4:first-child,
|
||||
.markdown-content h5:first-child,
|
||||
.markdown-content h6:first-child,
|
||||
.toastui-editor-contents h1:first-child,
|
||||
.toastui-editor-contents h2:first-child,
|
||||
.toastui-editor-contents h3:first-child,
|
||||
.toastui-editor-contents h4:first-child,
|
||||
.toastui-editor-contents h5:first-child,
|
||||
.toastui-editor-contents h6:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Reduce spacing between consecutive headings */
|
||||
.markdown-content h1 + h2,
|
||||
.toastui-editor-contents h1 + h2 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content h2 + h3,
|
||||
.toastui-editor-contents h2 + h3 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-content h3 + h4,
|
||||
.toastui-editor-contents h3 + h4 {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.markdown-content h4 + h5,
|
||||
.toastui-editor-contents h4 + h5 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content h5 + h6,
|
||||
.toastui-editor-contents h5 + h6 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-content p,
|
||||
.toastui-editor-contents p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol,
|
||||
.toastui-editor-contents ul,
|
||||
.toastui-editor-contents ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.toastui-editor-contents ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.markdown-content ol,
|
||||
.toastui-editor-contents ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown-content li,
|
||||
.toastui-editor-contents li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-content code,
|
||||
.toastui-editor-contents code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.2em 0.4em;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-content pre,
|
||||
.toastui-editor-contents pre {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-content pre code,
|
||||
.toastui-editor-contents pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.markdown-content blockquote,
|
||||
.toastui-editor-contents blockquote {
|
||||
border-left: 4px solid var(--border);
|
||||
padding-left: 1rem;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.markdown-content a,
|
||||
.toastui-editor-contents a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-content a:hover,
|
||||
.toastui-editor-contents a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content table,
|
||||
.toastui-editor-contents table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td,
|
||||
.toastui-editor-contents th,
|
||||
.toastui-editor-contents td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.toastui-editor-contents th {
|
||||
background-color: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content tr:nth-child(even),
|
||||
.toastui-editor-contents tr:nth-child(even) {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
184
mdedit/css/tailwind-utils.css
Normal file
184
mdedit/css/tailwind-utils.css
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/*
|
||||
* Tailwind utility subset for mdedit
|
||||
*
|
||||
* This file replaces the Tailwind Play CDN. It contains only the utility
|
||||
* classes actually used in template.html, hand-written to match Tailwind v3
|
||||
* output exactly. If new Tailwind classes are needed in template.html, add
|
||||
* them here and remove the class from this comment.
|
||||
*
|
||||
* Generated from: grep -o 'class="[^"]*"' template.html | tr ' ' '\n' | sort -u
|
||||
* Tailwind version parity: v3.x (default spacing scale, gray palette, etc.)
|
||||
*/
|
||||
|
||||
/* ── Reset ── */
|
||||
*, ::before, ::after { box-sizing: border-box; }
|
||||
|
||||
/* ── Display ── */
|
||||
.flex { display: flex; }
|
||||
.inline-flex { display: inline-flex; }
|
||||
/* .hidden lives in shared/base.css (uses !important) */
|
||||
|
||||
/* ── Flex direction ── */
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-row { flex-direction: row; }
|
||||
|
||||
/* ── Flex grow ── */
|
||||
.flex-1 { flex: 1 1 0%; }
|
||||
|
||||
/* ── Alignment ── */
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-center { justify-content: center; }
|
||||
|
||||
/* ── Gap ── */
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
.gap-6 { gap: 1.5rem; }
|
||||
|
||||
/* ── Overflow ── */
|
||||
.overflow-hidden { overflow: hidden; }
|
||||
.overflow-auto { overflow: auto; }
|
||||
|
||||
/* ── Sizing ── */
|
||||
.h-screen { height: 100vh; }
|
||||
.h-full { height: 100%; }
|
||||
.h-12 { height: 3rem; }
|
||||
.h-6 { height: 1.5rem; }
|
||||
.h-3\.5 { height: 0.875rem; }
|
||||
.h-24 { height: 6rem; }
|
||||
|
||||
/* ── Resize ── */
|
||||
.resize-none { resize: none; }
|
||||
|
||||
/* ── Border ── */
|
||||
.border-0 { border-width: 0; }
|
||||
|
||||
/* ── Outline ── */
|
||||
.focus\:outline-none:focus { outline: none; }
|
||||
.w-full { width: 100%; }
|
||||
.w-1 { width: 0.25rem; }
|
||||
.w-3\.5 { width: 0.875rem; }
|
||||
|
||||
/* ── Positioning ── */
|
||||
.relative { position: relative; }
|
||||
.z-10 { z-index: 10; }
|
||||
|
||||
/* ── Spacing ── */
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||
.pl-2 { padding-left: 0.5rem; }
|
||||
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
|
||||
/* ── Typography ── */
|
||||
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-medium { font-weight: 500; }
|
||||
.text-center { text-align: center; }
|
||||
.leading-none { line-height: 1; }
|
||||
.select-none { user-select: none; }
|
||||
|
||||
/* ── Colors — text ── */
|
||||
.text-white { color: #ffffff; }
|
||||
.text-gray-800 { color: #1f2937; }
|
||||
.text-gray-700 { color: #374151; }
|
||||
.text-gray-500 { color: #6b7280; }
|
||||
.text-amber-600 { color: #d97706; }
|
||||
|
||||
/* ── Colors — background ── */
|
||||
.bg-white { background-color: #ffffff; }
|
||||
.bg-gray-100 { background-color: #f3f4f6; }
|
||||
.bg-gray-200 { background-color: #e5e7eb; }
|
||||
.bg-transparent { background-color: transparent; }
|
||||
.bg-blue-500 { background-color: #3b82f6; }
|
||||
|
||||
/* ── Borders ── */
|
||||
.border { border-width: 1px; border-style: solid; }
|
||||
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
|
||||
.border-t { border-top-width: 1px; border-top-style: solid; }
|
||||
.border-gray-200 { border-color: #e5e7eb; }
|
||||
.border-gray-300 { border-color: #d1d5db; }
|
||||
.rounded { border-radius: 0.25rem; }
|
||||
|
||||
/* ── Opacity ── */
|
||||
.opacity-70 { opacity: 0.7; }
|
||||
.opacity-80 { opacity: 0.8; }
|
||||
|
||||
/* ── SVG ── */
|
||||
.fill-current { fill: currentColor; }
|
||||
|
||||
/* ── Cursor ── */
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.cursor-col-resize { cursor: col-resize; }
|
||||
|
||||
/* ── Transitions ── */
|
||||
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4,0,0.2,1); transition-duration: 150ms; }
|
||||
.transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke; transition-timing-function: cubic-bezier(0.4,0,0.2,1); transition-duration: 150ms; }
|
||||
.transition-opacity { transition-property: opacity; transition-timing-function: cubic-bezier(0.4,0,0.2,1); transition-duration: 150ms; }
|
||||
|
||||
/* ── Pseudo-class: hover ── */
|
||||
.hover\:bg-blue-500:hover { background-color: #3b82f6; }
|
||||
.hover\:bg-blue-600:hover { background-color: #2563eb; }
|
||||
.hover\:bg-gray-200:hover { background-color: #e5e7eb; }
|
||||
.hover\:opacity-80:hover { opacity: 0.8; }
|
||||
|
||||
/* ── Pseudo-class: disabled ── */
|
||||
.disabled\:bg-gray-400:disabled { background-color: #9ca3af; }
|
||||
.disabled\:cursor-not-allowed:disabled { cursor: not-allowed; }
|
||||
|
||||
/* ── Dark mode (prefers-color-scheme or manual [data-theme="dark"]) ── */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .dark\:bg-gray-700 { background-color: #374151; }
|
||||
:root:not([data-theme="light"]) .dark\:bg-gray-800 { background-color: #1f2937; }
|
||||
:root:not([data-theme="light"]) .dark\:bg-gray-900 { background-color: #111827; }
|
||||
:root:not([data-theme="light"]) .dark\:border-gray-600 { border-color: #4b5563; }
|
||||
:root:not([data-theme="light"]) .dark\:border-gray-700 { border-color: #374151; }
|
||||
:root:not([data-theme="light"]) .dark\:text-gray-200 { color: #e5e7eb; }
|
||||
:root:not([data-theme="light"]) .dark\:text-gray-400 { color: #9ca3af; }
|
||||
:root:not([data-theme="light"]) .dark\:hover\:bg-gray-700:hover { background-color: #374151; }
|
||||
:root:not([data-theme="light"]) .dark\:hover\:bg-gray-800:hover { background-color: #1f2937; }
|
||||
}
|
||||
|
||||
/* Manual dark override */
|
||||
[data-theme="dark"] .dark\:bg-gray-700 { background-color: #374151; }
|
||||
[data-theme="dark"] .dark\:bg-gray-800 { background-color: #1f2937; }
|
||||
[data-theme="dark"] .dark\:bg-gray-900 { background-color: #111827; }
|
||||
[data-theme="dark"] .dark\:border-gray-600 { border-color: #4b5563; }
|
||||
[data-theme="dark"] .dark\:border-gray-700 { border-color: #374151; }
|
||||
[data-theme="dark"] .dark\:text-gray-200 { color: #e5e7eb; }
|
||||
[data-theme="dark"] .dark\:text-gray-400 { color: #9ca3af; }
|
||||
[data-theme="dark"] .dark\:hover\:bg-gray-700:hover { background-color: #374151; }
|
||||
[data-theme="dark"] .dark\:hover\:bg-gray-800:hover { background-color: #1f2937; }
|
||||
|
||||
/* Manual light override — ensure bg-white/bg-gray-100 are NOT overridden by above */
|
||||
[data-theme="light"] .dark\:bg-gray-700,
|
||||
[data-theme="light"] .dark\:bg-gray-800,
|
||||
[data-theme="light"] .dark\:bg-gray-900 { background-color: revert; }
|
||||
|
||||
/* ── Directional spacing (used in JS-generated elements) ── */
|
||||
.ml-1 { margin-left: 0.25rem; }
|
||||
.ml-4 { margin-left: 1rem; }
|
||||
.mr-1 { margin-right: 0.25rem; }
|
||||
.pl-0 { padding-left: 0; }
|
||||
.pl-4 { padding-left: 1rem; }
|
||||
|
||||
/* ── Additional missing utilities ── */
|
||||
.whitespace-nowrap { white-space: nowrap; }
|
||||
.text-ellipsis { text-overflow: ellipsis; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.border-r { border-right-width: 1px; border-right-style: solid; }
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.text-amber-500 { color: #f59e0b; }
|
||||
.text-blue-600 { color: #2563eb; }
|
||||
.hover\:bg-gray-100:hover { background-color: #f3f4f6; }
|
||||
.hover\:text-blue-800:hover { color: #1e40af; }
|
||||
.hover\:underline:hover { text-decoration: underline; }
|
||||
276
mdedit/css/toc.css
Normal file
276
mdedit/css/toc.css
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
/* Table of Contents styles */
|
||||
.toc-pane {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toc-container,
|
||||
.toc-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.toc-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.toc-depth-selector {
|
||||
font-size: 0.85rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* TOC heading level styles */
|
||||
.toc-level-1 > a {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toc-level-2 > a {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toc-level-3 > a {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc-level-4 > a {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc-level-5 > a,
|
||||
.toc-level-6 > a {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Nested list spacing */
|
||||
.toc-list ul {
|
||||
list-style: none;
|
||||
padding-left: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toc-list li {
|
||||
margin-bottom: 1px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.toc-list li a {
|
||||
display: block;
|
||||
padding: 2px 6px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.toc-list li a:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Active TOC item highlighting */
|
||||
.toc-list li.toc-active {
|
||||
background-color: var(--primary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Use high-specificity selectors to override per-level color rules */
|
||||
.toc-list li.toc-active > a,
|
||||
.toc-list li.toc-active > a:hover,
|
||||
.toc-list li.toc-level-1.toc-active > a,
|
||||
.toc-list li.toc-level-2.toc-active > a,
|
||||
.toc-list li.toc-level-3.toc-active > a,
|
||||
.toc-list li.toc-level-4.toc-active > a,
|
||||
.toc-list li.toc-level-5.toc-active > a,
|
||||
.toc-list li.toc-level-6.toc-active > a {
|
||||
color: var(--text-light);
|
||||
border-bottom-color: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-1 {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-1 a {
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--primary);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Tree-style connecting lines for TOC hierarchy */
|
||||
.toc-list li.toc-level-2 {
|
||||
font-weight: 650;
|
||||
font-size: 0.9rem;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-2::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
border-left: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-2 a {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-3 {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
padding-left: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-3::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
border-left: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-3 a {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-4 {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
padding-left: 48px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-4::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 38px;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
border-left: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-4 a {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-5 {
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
padding-left: 64px;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-5::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 54px;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
border-left: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-5 a {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-6 {
|
||||
font-weight: 600;
|
||||
font-size: 0.65rem;
|
||||
padding-left: 80px;
|
||||
font-style: italic;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-6::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 70px;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
border-left: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-6 a {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Vertical connecting lines */
|
||||
.toc-list li:not(.toc-level-1)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 50%;
|
||||
bottom: -2px;
|
||||
border-left: 1px solid var(--border);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-3::after {
|
||||
left: 22px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-4::after {
|
||||
left: 38px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-5::after {
|
||||
left: 54px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-6::after {
|
||||
left: 70px;
|
||||
}
|
||||
24
mdedit/js/app.js
Normal file
24
mdedit/js/app.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Global application state and constants
|
||||
*/
|
||||
|
||||
// Set to true to enable verbose console logging for development.
|
||||
const DEBUG = false;
|
||||
|
||||
// Check if File System Access API is available
|
||||
const hasFileSystemAccess = 'showDirectoryPicker' in window;
|
||||
|
||||
// Directory and file handles
|
||||
let directoryHandle = null;
|
||||
let fileTree = {};
|
||||
let currentFileHandle = null;
|
||||
|
||||
// Map to store editor instances for each file
|
||||
// Key: file path, Value: { editor, container, tocContainer, etc. }
|
||||
const editorInstances = new Map();
|
||||
|
||||
// Current TOC max depth (1-6)
|
||||
let tocMaxDepth = 3;
|
||||
|
||||
// Scratchpad ID constant
|
||||
const SCRATCHPAD_ID = '__scratchpad__';
|
||||
403
mdedit/js/editor.js
Normal file
403
mdedit/js/editor.js
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
/**
|
||||
* Toast UI Editor initialization and management
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize or update the Toast UI Editor for a file
|
||||
* @param {string} content - Content to display
|
||||
* @param {boolean} isMarkdown - Whether content is markdown
|
||||
* @param {string} filePath - Path of the file
|
||||
* @param {string} fileName - Name of the file
|
||||
* @param {FileSystemFileHandle} fileHandle - File handle for saving
|
||||
* @param {number} lastModified - Timestamp of last modification
|
||||
*/
|
||||
function initializeEditor(content, isMarkdown = true, filePath = '', fileName = '', fileHandle = null, lastModified = null) {
|
||||
// Parse front matter
|
||||
let frontMatterData = {};
|
||||
let markdownBody = content;
|
||||
|
||||
if (isMarkdown && content) {
|
||||
try {
|
||||
const parsed = parseFrontMatter(content);
|
||||
frontMatterData = parsed.data;
|
||||
markdownBody = parsed.content;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse front matter:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) {
|
||||
alert('Error: content-container element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide all file view containers
|
||||
document.querySelectorAll('.file-view-container').forEach(container => {
|
||||
container.style.display = 'none';
|
||||
});
|
||||
|
||||
// Check if file already has an instance
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existingInstance = editorInstances.get(filePath);
|
||||
if (existingInstance.fileViewContainer) {
|
||||
existingInstance.fileViewContainer.style.display = 'flex';
|
||||
}
|
||||
return existingInstance.editor;
|
||||
}
|
||||
|
||||
// Create file view container
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
// Create file header
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
|
||||
// Button container for alignment
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.className = 'flex gap-2';
|
||||
|
||||
// Determine if this is a scratchpad (no file handle)
|
||||
const isScratchpad = !fileHandle;
|
||||
|
||||
// Save button (or Save As for scratchpads)
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.className = 'btn btn-primary btn-sm';
|
||||
saveButton.textContent = isScratchpad ? 'Save As...' : 'Save File';
|
||||
saveButton.disabled = !isScratchpad; // Scratchpads can always save
|
||||
buttonContainer.appendChild(saveButton);
|
||||
|
||||
// Reload button (only for files, not scratchpads)
|
||||
let reloadButton = null;
|
||||
if (!isScratchpad) {
|
||||
reloadButton = document.createElement('button');
|
||||
reloadButton.className = 'btn btn-secondary btn-sm';
|
||||
reloadButton.textContent = 'Reload from Disk';
|
||||
reloadButton.title = 'Reload file from disk (discards unsaved changes)';
|
||||
buttonContainer.appendChild(reloadButton);
|
||||
}
|
||||
|
||||
fileHeader.appendChild(buttonContainer);
|
||||
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
// Content area
|
||||
const contentArea = document.createElement('div');
|
||||
contentArea.className = 'flex flex-col flex-1 overflow-hidden';
|
||||
|
||||
// Editor area with TOC
|
||||
const editorArea = document.createElement('div');
|
||||
editorArea.className = 'flex flex-row flex-1 overflow-hidden';
|
||||
|
||||
// TOC pane (markdown only)
|
||||
let tocContainer = null;
|
||||
let frontMatterTextarea = null;
|
||||
if (isMarkdown) {
|
||||
const tocPane = document.createElement('div');
|
||||
tocPane.className = 'toc-pane bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700';
|
||||
tocPane.style.width = '325px';
|
||||
tocPane.style.minWidth = '150px';
|
||||
|
||||
// Front matter nav bar (collapsible)
|
||||
const frontMatterNav = document.createElement('div');
|
||||
frontMatterNav.className = 'front-matter-nav border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const frontMatterHeader = document.createElement('div');
|
||||
frontMatterHeader.className = 'front-matter-header px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700 cursor-pointer flex items-center gap-2';
|
||||
|
||||
const toggleIcon = document.createElement('span');
|
||||
toggleIcon.textContent = '▼';
|
||||
toggleIcon.className = 'toggle-icon text-sm';
|
||||
frontMatterHeader.appendChild(toggleIcon);
|
||||
|
||||
const headerText = document.createElement('span');
|
||||
headerText.textContent = 'YAML Front Matter';
|
||||
frontMatterHeader.appendChild(headerText);
|
||||
|
||||
frontMatterNav.appendChild(frontMatterHeader);
|
||||
|
||||
frontMatterTextarea = document.createElement('textarea');
|
||||
frontMatterTextarea.className = 'front-matter-textarea w-full px-4 py-2 text-sm focus:outline-none resize-none overflow-x-auto';
|
||||
frontMatterTextarea.placeholder = 'title: Document Title\ndate: 2024-01-01\ntags: [example]';
|
||||
|
||||
// Set front matter content
|
||||
if (frontMatterData && Object.keys(frontMatterData).length > 0) {
|
||||
try {
|
||||
let yamlText = '';
|
||||
for (const [key, value] of Object.entries(frontMatterData)) {
|
||||
if (Array.isArray(value)) {
|
||||
yamlText += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`;
|
||||
} else {
|
||||
yamlText += `${key}: ${value}\n`;
|
||||
}
|
||||
}
|
||||
frontMatterTextarea.value = yamlText.trim();
|
||||
} catch (error) {
|
||||
console.warn('Failed to stringify front matter:', error);
|
||||
frontMatterTextarea.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
frontMatterNav.appendChild(frontMatterTextarea);
|
||||
tocPane.appendChild(frontMatterNav);
|
||||
|
||||
const tocHeader = document.createElement('div');
|
||||
tocHeader.className = 'toc-header px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const tocTitle = document.createElement('span');
|
||||
tocTitle.textContent = 'Table of Contents';
|
||||
tocHeader.appendChild(tocTitle);
|
||||
|
||||
const tocDepthSelector = document.createElement('select');
|
||||
tocDepthSelector.className = 'toc-depth-selector';
|
||||
tocDepthSelector.innerHTML = `
|
||||
<option value="6">All Levels</option>
|
||||
<option value="1">H1 Only</option>
|
||||
<option value="2">H1-H2</option>
|
||||
<option value="3" selected>H1-H3</option>
|
||||
<option value="4">H1-H4</option>
|
||||
<option value="5">H1-H5</option>
|
||||
`;
|
||||
tocHeader.appendChild(tocDepthSelector);
|
||||
|
||||
tocPane.appendChild(tocHeader);
|
||||
|
||||
tocContainer = document.createElement('div');
|
||||
tocContainer.className = 'toc-container toc-content p-4 h-full overflow-auto';
|
||||
tocPane.appendChild(tocContainer);
|
||||
|
||||
// Set up TOC container overflow when front matter is toggled
|
||||
let fmIsCollapsed = false;
|
||||
frontMatterHeader.addEventListener('click', () => {
|
||||
fmIsCollapsed = !fmIsCollapsed;
|
||||
frontMatterNav.classList.toggle('collapsed', fmIsCollapsed);
|
||||
toggleIcon.textContent = fmIsCollapsed ? '▶' : '▼';
|
||||
});
|
||||
|
||||
// Auto-size textarea: no vertical scroll, horizontal scroll for long lines
|
||||
frontMatterTextarea.style.overflowY = 'hidden';
|
||||
frontMatterTextarea.style.overflowX = 'auto';
|
||||
const autoResizeFm = () => {
|
||||
frontMatterTextarea.style.height = 'auto';
|
||||
frontMatterTextarea.style.height = frontMatterTextarea.scrollHeight + 'px';
|
||||
};
|
||||
frontMatterTextarea.addEventListener('input', autoResizeFm);
|
||||
// Defer initial resize until element is in the DOM and has layout
|
||||
requestAnimationFrame(() => requestAnimationFrame(autoResizeFm));
|
||||
|
||||
editorArea.appendChild(tocPane);
|
||||
|
||||
// TOC resizer
|
||||
const tocResizer = document.createElement('div');
|
||||
tocResizer.className = 'pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500';
|
||||
tocResizer.setAttribute('data-resizer-for', 'toc-pane');
|
||||
contentArea.appendChild(tocResizer);
|
||||
|
||||
makeResizable(tocResizer, tocPane);
|
||||
|
||||
// TOC depth selector event
|
||||
tocDepthSelector.addEventListener('change', function () {
|
||||
const depth = parseInt(this.value);
|
||||
if (window.updateToc && editorInstance) {
|
||||
const currentContent = editorInstance.getMarkdown();
|
||||
window.updateToc(currentContent, tocContainer, editorInstance, depth);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Editor container
|
||||
const editorContainer = document.createElement('div');
|
||||
editorContainer.className = 'editor-instance flex-1 overflow-hidden';
|
||||
editorArea.appendChild(editorContainer);
|
||||
|
||||
contentArea.appendChild(editorArea);
|
||||
fileViewContainer.appendChild(contentArea);
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
// Check Toast UI availability
|
||||
if (typeof toastui === 'undefined') {
|
||||
alert('Error: Toast UI library not loaded!');
|
||||
editorContainer.innerHTML = '<div style="padding: 20px; background: #ffeeee; color: red;">Error: Toast UI library not loaded!</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let editorInstance;
|
||||
|
||||
try {
|
||||
// Initialize Toast UI Editor
|
||||
const editor = new toastui.Editor({
|
||||
el: editorContainer,
|
||||
height: '100%',
|
||||
initialEditType: 'markdown',
|
||||
previewStyle: 'vertical',
|
||||
initialValue: markdownBody,
|
||||
toolbarItems: [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
['hr', 'quote'],
|
||||
['ul', 'ol', 'task', 'indent', 'outdent'],
|
||||
['table', 'image', 'link'],
|
||||
['code', 'codeblock']
|
||||
]
|
||||
});
|
||||
|
||||
editorInstance = editor;
|
||||
|
||||
if (!isMarkdown) {
|
||||
editorInstance.changeMode('wysiwyg');
|
||||
}
|
||||
|
||||
// Generate initial TOC
|
||||
if (isMarkdown && window.updateToc && tocContainer) {
|
||||
try {
|
||||
window.updateToc(markdownBody, tocContainer, editorInstance, tocMaxDepth);
|
||||
} catch (error) {
|
||||
console.error('Error generating TOC:', error);
|
||||
}
|
||||
|
||||
const debouncedUpdateToc = debounce(() => {
|
||||
const currentContent = editorInstance.getMarkdown();
|
||||
window.updateToc(currentContent, tocContainer, editorInstance, tocMaxDepth);
|
||||
}, 300);
|
||||
|
||||
editorInstance.on('change', () => {
|
||||
debouncedUpdateToc();
|
||||
|
||||
const instanceData = editorInstances.get(filePath);
|
||||
if (instanceData && !instanceData.isDirty) {
|
||||
instanceData.isDirty = true;
|
||||
updateFileDirtyStatus(filePath, true);
|
||||
updateUnsavedCount();
|
||||
}
|
||||
saveButton.disabled = false;
|
||||
});
|
||||
|
||||
// Scroll listener for TOC highlighting
|
||||
const mdPreview = editorInstance.getEditorElements().mdPreview;
|
||||
if (mdPreview) {
|
||||
let activeTimeout = null;
|
||||
let lastHeader = null;
|
||||
|
||||
const updateActiveHeader = () => {
|
||||
// Re-query live headings (TOC may have been regenerated)
|
||||
const liveHeaders = mdPreview.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
const previewRect = mdPreview.getBoundingClientRect();
|
||||
// Use a threshold slightly below the top so a header touching
|
||||
// the top edge counts as "active"
|
||||
const threshold = previewRect.top + 4;
|
||||
let activeHeader = null;
|
||||
for (const header of liveHeaders) {
|
||||
if (header.getBoundingClientRect().top <= threshold) {
|
||||
activeHeader = header.textContent.trim();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (activeHeader !== lastHeader) {
|
||||
lastHeader = activeHeader;
|
||||
setActiveTocItem(tocContainer, activeHeader);
|
||||
}
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(activeTimeout);
|
||||
activeTimeout = requestAnimationFrame(updateActiveHeader);
|
||||
};
|
||||
|
||||
mdPreview.addEventListener('scroll', onScroll);
|
||||
}
|
||||
} else {
|
||||
editorInstance.on('change', () => {
|
||||
const instanceData = editorInstances.get(filePath);
|
||||
if (instanceData && !instanceData.isDirty) {
|
||||
instanceData.isDirty = true;
|
||||
updateFileDirtyStatus(filePath, true);
|
||||
updateUnsavedCount();
|
||||
}
|
||||
saveButton.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Front matter change listener
|
||||
if (frontMatterTextarea) {
|
||||
frontMatterTextarea.addEventListener('input', () => {
|
||||
const instanceData = editorInstances.get(filePath);
|
||||
if (instanceData && !instanceData.isDirty) {
|
||||
instanceData.isDirty = true;
|
||||
updateFileDirtyStatus(filePath, true);
|
||||
updateUnsavedCount();
|
||||
}
|
||||
saveButton.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Button event listeners
|
||||
saveButton.addEventListener('click', async () => {
|
||||
if (isScratchpad) {
|
||||
// For scratchpads, use Save As
|
||||
const content = editorInstance.getMarkdown();
|
||||
const savedHandle = await saveFileAs(content, 'untitled.md');
|
||||
if (savedHandle && hasFileSystemAccess) {
|
||||
// Check if saved to current directory - add to file tree
|
||||
if (directoryHandle) {
|
||||
try {
|
||||
// Try to get the file from the directory to verify it's there
|
||||
const checkHandle = await directoryHandle.getFileHandle(savedHandle.name);
|
||||
// File is in current directory, add to tree
|
||||
fileTree.entries[savedHandle.name] = {
|
||||
name: savedHandle.name,
|
||||
type: 'file',
|
||||
handle: checkHandle
|
||||
};
|
||||
renderFileTree();
|
||||
|
||||
} catch (e) {
|
||||
// File not in current directory, that's fine
|
||||
}
|
||||
}
|
||||
// Clear scratchpad content after successful save
|
||||
editorInstance.setMarkdown('');
|
||||
saveButton.disabled = true;
|
||||
const instanceData = editorInstances.get(filePath);
|
||||
if (instanceData) {
|
||||
instanceData.isDirty = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
saveFile(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
if (reloadButton) {
|
||||
reloadButton.addEventListener('click', async () => {
|
||||
await reloadFileFromDisk(filePath);
|
||||
});
|
||||
}
|
||||
|
||||
// Store instance data
|
||||
const instanceData = {
|
||||
editor: editor,
|
||||
fileViewContainer: fileViewContainer,
|
||||
tocContainer: tocContainer,
|
||||
saveButton: saveButton,
|
||||
reloadButton: reloadButton,
|
||||
frontMatterTextarea: frontMatterTextarea,
|
||||
frontMatterData: frontMatterData,
|
||||
fileHandle: fileHandle,
|
||||
lastModified: lastModified,
|
||||
isDirty: false
|
||||
};
|
||||
|
||||
editorInstances.set(filePath, instanceData);
|
||||
|
||||
return editorInstance;
|
||||
} catch (error) {
|
||||
console.error('Error initializing editor:', error);
|
||||
alert(`Error initializing Toast UI Editor: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
81
mdedit/js/events.js
Normal file
81
mdedit/js/events.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Event listeners setup
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set up all event listeners for the application
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// Select directory button
|
||||
const selectDirectoryBtn = document.getElementById('select-directory');
|
||||
if (selectDirectoryBtn) {
|
||||
selectDirectoryBtn.addEventListener('click', openDirectory);
|
||||
}
|
||||
|
||||
// Refresh directory button
|
||||
const refreshDirectoryBtn = document.getElementById('refresh-directory');
|
||||
if (refreshDirectoryBtn) {
|
||||
refreshDirectoryBtn.addEventListener('click', refreshDirectory);
|
||||
}
|
||||
|
||||
// New file (root) button
|
||||
const newFileRootBtn = document.getElementById('new-file-root');
|
||||
if (newFileRootBtn) {
|
||||
newFileRootBtn.addEventListener('click', () => {
|
||||
if (directoryHandle) {
|
||||
createNewFile('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Save All button
|
||||
const saveAllBtn = document.getElementById('save-all');
|
||||
if (saveAllBtn) {
|
||||
saveAllBtn.addEventListener('click', saveAllFiles);
|
||||
}
|
||||
|
||||
// Warn when leaving with unsaved changes
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
let hasUnsavedChanges = false;
|
||||
|
||||
editorInstances.forEach((instanceData) => {
|
||||
if (instanceData.isDirty) {
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasUnsavedChanges) {
|
||||
e.preventDefault();
|
||||
return 'You have unsaved changes. If you leave now, your changes will be lost.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up TOC depth selector
|
||||
*/
|
||||
function setupTocDepthSelector() {
|
||||
const depthSelector = document.getElementById('toc-depth-selector');
|
||||
if (!depthSelector) return;
|
||||
|
||||
depthSelector.value = tocMaxDepth.toString();
|
||||
|
||||
depthSelector.addEventListener('change', function () {
|
||||
tocMaxDepth = parseInt(this.value, 10);
|
||||
|
||||
if (currentFileHandle && currentFileHandle.name.match(/\.(md|markdown)$/i)) {
|
||||
const filePath = currentFileHandle.name;
|
||||
const instance = editorInstances.get(filePath);
|
||||
|
||||
if (instance && instance.editor && instance.tocContainer) {
|
||||
const content = instance.editor.getMarkdown();
|
||||
|
||||
try {
|
||||
window.updateToc(content, instance.tocContainer, instance.editor, tocMaxDepth);
|
||||
} catch (error) {
|
||||
console.error('Error updating TOC depth:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
400
mdedit/js/file-ops.js
Normal file
400
mdedit/js/file-ops.js
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
/**
|
||||
* File management operations (create, rename, delete)
|
||||
* Plain functions, no module wrapper
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve a node in fileTree by filePath
|
||||
* @param {string} filePath - Path like 'subdir/file.md' or ''
|
||||
* @returns {Object|null} The node object or null if not found
|
||||
*/
|
||||
function resolveNode(filePath) {
|
||||
if (!filePath) return fileTree;
|
||||
const parts = filePath.split('/');
|
||||
let node = fileTree;
|
||||
for (const part of parts) {
|
||||
if (!node.entries || !node.entries[part]) return null;
|
||||
node = node.entries[part];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the parent directory handle for a given file path
|
||||
* @param {string} filePath - Full path like 'subdir/file.md'
|
||||
* @returns {FileSystemDirectoryHandle|null} Parent directory handle or null
|
||||
*/
|
||||
function resolveParentDirHandle(filePath) {
|
||||
const parts = filePath.split('/');
|
||||
if (parts.length === 1) return directoryHandle;
|
||||
let node = fileTree;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
node = node.entries[parts[i]];
|
||||
if (!node) return null;
|
||||
}
|
||||
return node.handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new file
|
||||
* @param {string} parentDirPath - '' for root, or 'subdir', 'a/b/c'
|
||||
*/
|
||||
async function createNewFile(parentDirPath) {
|
||||
// Resolve parent directory handle first (no user activation needed for reads)
|
||||
let parentHandle;
|
||||
if (parentDirPath === '') {
|
||||
parentHandle = directoryHandle;
|
||||
} else {
|
||||
const node = resolveNode(parentDirPath);
|
||||
if (!node || !node.handle) {
|
||||
alert('Could not locate parent directory.');
|
||||
return;
|
||||
}
|
||||
parentHandle = node.handle;
|
||||
}
|
||||
|
||||
// Show in-page modal and wait for user to confirm or cancel.
|
||||
// Returns the filename string, or null if cancelled.
|
||||
const name = await new Promise((resolve) => {
|
||||
const modal = document.getElementById('new-file-modal');
|
||||
const input = document.getElementById('new-file-input');
|
||||
const confirmBtn = document.getElementById('new-file-confirm');
|
||||
const cancelBtn = document.getElementById('new-file-cancel');
|
||||
|
||||
input.value = 'untitled.md';
|
||||
modal.classList.remove('hidden');
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
function cleanup() {
|
||||
modal.classList.add('hidden');
|
||||
confirmBtn.removeEventListener('click', onConfirm);
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
input.removeEventListener('keydown', onKey);
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
const val = input.value.trim();
|
||||
cleanup();
|
||||
resolve(val || null);
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Enter') onConfirm();
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}
|
||||
|
||||
confirmBtn.addEventListener('click', onConfirm);
|
||||
cancelBtn.addEventListener('click', onCancel);
|
||||
input.addEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
if (!name) {
|
||||
if (DEBUG) console.log('New file creation cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if (name.includes('/') || name.includes('\\')) {
|
||||
alert('Invalid filename: cannot contain / or \\.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
try {
|
||||
await parentHandle.getFileHandle(name);
|
||||
const overwrite = window.confirm('A file named "' + name + '" already exists. Overwrite it?');
|
||||
if (!overwrite) return;
|
||||
} catch (e) {
|
||||
if (e.name !== 'NotFoundError') throw e;
|
||||
}
|
||||
|
||||
// Create the file — this must happen after the modal's button click
|
||||
// which is the user activation token.
|
||||
try {
|
||||
const newHandle = await parentHandle.getFileHandle(name, { create: true });
|
||||
|
||||
const writable = await newHandle.createWritable();
|
||||
await writable.write('');
|
||||
await writable.close();
|
||||
|
||||
if (DEBUG) console.log(`Created new file: ${parentDirPath ? parentDirPath + '/' : ''}${name}`);
|
||||
|
||||
await refreshDirectory();
|
||||
|
||||
const newFilePath = parentDirPath ? parentDirPath + '/' + name : name;
|
||||
const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]');
|
||||
if (element) {
|
||||
handleFileClick(newHandle, newFilePath, element);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating new file:', error);
|
||||
alert('Error creating file: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a file or directory
|
||||
* @param {string} filePath - Full path like 'subdir/file.md'
|
||||
* @param {boolean} isDirectory - true if renaming a directory (not supported on Chrome)
|
||||
*/
|
||||
async function renameEntry(filePath, isDirectory) {
|
||||
const currentName = filePath.split('/').pop();
|
||||
const newName = window.prompt('Rename to:', currentName);
|
||||
|
||||
if (newName === null || newName === currentName) {
|
||||
if (DEBUG) console.log('Rename cancelled or unchanged');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if (newName.includes('/') || newName.includes('\\') || newName.trim() === '') {
|
||||
alert('Invalid filename: cannot contain / or \\ and must not be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve parent directory handle
|
||||
const parentHandle = resolveParentDirHandle(filePath);
|
||||
if (!parentHandle) {
|
||||
alert('Could not locate parent directory.');
|
||||
return;
|
||||
}
|
||||
|
||||
// For files: rename via File System Access API
|
||||
if (!isDirectory) {
|
||||
try {
|
||||
// Check if new name already exists (file or directory)
|
||||
try {
|
||||
const existing = await parentHandle.getFileHandle(newName);
|
||||
// A file with that name exists
|
||||
const overwrite = window.confirm('A file named "' + newName + '" already exists. Overwrite?');
|
||||
if (!overwrite) return;
|
||||
} catch (fileErr) {
|
||||
if (fileErr.name === 'TypeMismatchError') {
|
||||
// A directory with that name exists
|
||||
window.alert('A folder named "' + newName + '" already exists. Choose a different name.');
|
||||
return;
|
||||
}
|
||||
if (fileErr.name !== 'NotFoundError') throw fileErr;
|
||||
// NotFoundError = safe to create
|
||||
}
|
||||
|
||||
const oldHandle = resolveNode(filePath);
|
||||
if (!oldHandle || !oldHandle.handle) {
|
||||
alert('Could not find file to rename.');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await oldHandle.handle.getFile();
|
||||
const content = await file.text();
|
||||
|
||||
const newHandle = await parentHandle.getFileHandle(newName, { create: true });
|
||||
const writable = await newHandle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
const newFile = await newHandle.getFile();
|
||||
|
||||
await parentHandle.removeEntry(currentName);
|
||||
|
||||
// Update editor instances
|
||||
if (editorInstances.has(filePath)) {
|
||||
const instance = editorInstances.get(filePath);
|
||||
const newFilePath = filePath.substring(0, filePath.length - currentName.length) + newName;
|
||||
|
||||
// Remove old instance
|
||||
const data = editorInstances.get(filePath);
|
||||
if (data.fileViewContainer) {
|
||||
data.fileViewContainer.classList.add('hidden');
|
||||
}
|
||||
editorInstances.delete(filePath);
|
||||
|
||||
// Re-add with new path
|
||||
editorInstances.set(newFilePath, { ...data, fileHandle: newHandle, lastModified: newFile.lastModified });
|
||||
|
||||
// Update active state
|
||||
if (instance.fileViewContainer) {
|
||||
instance.fileViewContainer.classList.remove('hidden');
|
||||
instance.fileViewContainer.dataset.path = newFilePath;
|
||||
}
|
||||
|
||||
// Update fileTree entries
|
||||
const parts = filePath.split('/');
|
||||
const fileName = parts.pop();
|
||||
const dirPath = parts.join('/');
|
||||
let targetEntries = fileTree.entries;
|
||||
if (dirPath) {
|
||||
const dirParts = dirPath.split('/');
|
||||
let current = fileTree;
|
||||
for (const part of dirParts) {
|
||||
current = current.entries[part];
|
||||
}
|
||||
targetEntries = current.entries;
|
||||
}
|
||||
if (targetEntries && targetEntries[currentName]) {
|
||||
delete targetEntries[currentName];
|
||||
targetEntries[newName] = {
|
||||
name: newName,
|
||||
type: 'file',
|
||||
handle: newHandle
|
||||
};
|
||||
}
|
||||
|
||||
renderFileTree();
|
||||
restoreActiveFile(newFilePath);
|
||||
} else {
|
||||
renderFileTree();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error renaming file:', error);
|
||||
alert('Error renaming file: ' + error.message);
|
||||
}
|
||||
} else {
|
||||
// For directories: not supported by browser API
|
||||
alert('Directory rename is not supported by the browser File System API. Please rename the folder in your OS file manager and refresh.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file or directory
|
||||
* @param {string} filePath - Full path like 'subdir/file.md' or 'subdir'
|
||||
* @param {boolean} isDirectory - true if deleting a directory
|
||||
*/
|
||||
async function deleteEntry(filePath, isDirectory) {
|
||||
const name = filePath.split('/').pop();
|
||||
|
||||
const message = isDirectory
|
||||
? 'Delete folder "' + name + '" and all its contents?'
|
||||
: 'Delete "' + name + '"?';
|
||||
|
||||
const ok = window.confirm(message);
|
||||
if (!ok) {
|
||||
if (DEBUG) console.log('Delete cancelled by user');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve parent directory handle
|
||||
const parentHandle = resolveParentDirHandle(filePath);
|
||||
if (!parentHandle) {
|
||||
alert('Could not locate parent directory.');
|
||||
return;
|
||||
}
|
||||
|
||||
let deleted = false;
|
||||
try {
|
||||
await parentHandle.removeEntry(name, { recursive: isDirectory });
|
||||
deleted = true;
|
||||
} catch (error) {
|
||||
if (error.name === 'NotFoundError') {
|
||||
// Already gone — treat as success for cleanup purposes
|
||||
deleted = true;
|
||||
} else {
|
||||
console.error('Error deleting entry:', error);
|
||||
alert('Error deleting entry: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (deleted) {
|
||||
// Close editor if open
|
||||
if (!isDirectory && editorInstances.has(filePath)) {
|
||||
closeEditorInstance(filePath);
|
||||
} else if (isDirectory) {
|
||||
// Close any editors under this directory
|
||||
const dirsToClose = [];
|
||||
editorInstances.forEach(function(instance, key) {
|
||||
if (key === filePath || key.startsWith(filePath + '/')) {
|
||||
dirsToClose.push(key);
|
||||
}
|
||||
});
|
||||
dirsToClose.forEach(function(key) {
|
||||
closeEditorInstance(key);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from fileTree entries
|
||||
const parts = filePath.split('/');
|
||||
const entryName = parts.pop();
|
||||
const dirPath = parts.join('/');
|
||||
let targetEntries = fileTree.entries;
|
||||
if (dirPath) {
|
||||
const dirParts = dirPath.split('/');
|
||||
let current = fileTree;
|
||||
for (const part of dirParts) {
|
||||
current = current.entries[part];
|
||||
}
|
||||
targetEntries = current.entries;
|
||||
}
|
||||
if (targetEntries && targetEntries[entryName]) {
|
||||
delete targetEntries[entryName];
|
||||
}
|
||||
|
||||
renderFileTree();
|
||||
updateStatusCountsFromTree();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an editor instance and show welcome screen if no files open
|
||||
* @param {string} filePath - Path of file to close
|
||||
*/
|
||||
function closeEditorInstance(filePath) {
|
||||
const instance = editorInstances.get(filePath);
|
||||
if (!instance) return;
|
||||
|
||||
if (instance.fileViewContainer) {
|
||||
instance.fileViewContainer.classList.add('hidden');
|
||||
}
|
||||
editorInstances.delete(filePath);
|
||||
|
||||
// Check if any visible file-view-container children remain
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (contentContainer) {
|
||||
const visibleChildren = Array.from(contentContainer.querySelectorAll('.file-view-container'))
|
||||
.filter(function(el) { return !el.classList.contains('hidden'); });
|
||||
if (visibleChildren.length === 0) {
|
||||
document.getElementById('welcome-screen').classList.remove('hidden');
|
||||
contentContainer.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore active file state after rename
|
||||
* @param {string} newFilePath - New path of the file
|
||||
*/
|
||||
function restoreActiveFile(newFilePath) {
|
||||
const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]');
|
||||
if (element) {
|
||||
element.classList.add('active-file');
|
||||
element.style.backgroundColor = '';
|
||||
element.style.color = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status counts from fileTree
|
||||
*/
|
||||
function updateStatusCountsFromTree() {
|
||||
let folderCount = 0;
|
||||
let fileCount = 0;
|
||||
|
||||
function countEntries(entries) {
|
||||
if (!entries) return;
|
||||
for (const [name, item] of Object.entries(entries)) {
|
||||
if (item.type === 'directory') {
|
||||
folderCount++;
|
||||
countEntries(item.entries);
|
||||
} else if (item.type === 'file') {
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
countEntries(fileTree.entries);
|
||||
updateStatusCounts(folderCount, fileCount);
|
||||
}
|
||||
531
mdedit/js/file-system.js
Normal file
531
mdedit/js/file-system.js
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
/**
|
||||
* File system operations using File System Access API
|
||||
*/
|
||||
|
||||
/**
|
||||
* Open the scratchpad editor
|
||||
*/
|
||||
function openScratchpad() {
|
||||
// Check if scratchpad already exists
|
||||
if (editorInstances.has(SCRATCHPAD_ID)) {
|
||||
// Just show it
|
||||
const instance = editorInstances.get(SCRATCHPAD_ID);
|
||||
document.getElementById('welcome-screen').classList.add('hidden');
|
||||
document.getElementById('content-container').classList.remove('hidden');
|
||||
|
||||
// Hide all other editors, show scratchpad
|
||||
editorInstances.forEach((data, path) => {
|
||||
if (data.fileViewContainer) {
|
||||
data.fileViewContainer.style.display = path === SCRATCHPAD_ID ? 'flex' : 'none';
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide welcome screen, show content container
|
||||
document.getElementById('welcome-screen').classList.add('hidden');
|
||||
document.getElementById('content-container').classList.remove('hidden');
|
||||
|
||||
// Initialize editor with no file handle
|
||||
initializeEditor('', true, SCRATCHPAD_ID, 'Scratchpad', null, null);
|
||||
|
||||
// Mark as scratchpad
|
||||
const instance = editorInstances.get(SCRATCHPAD_ID);
|
||||
if (instance) {
|
||||
instance.isScratchpad = true;
|
||||
}
|
||||
|
||||
if (DEBUG) console.log('Opened scratchpad');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save file using Save As dialog (for scratchpads or new saves)
|
||||
* @param {string} content - Content to save
|
||||
* @param {string} suggestedName - Suggested filename
|
||||
* @returns {Promise<FileSystemFileHandle|null>} File handle if saved, null otherwise
|
||||
*/
|
||||
async function saveFileAs(content, suggestedName = 'untitled.md') {
|
||||
if (hasFileSystemAccess) {
|
||||
try {
|
||||
const fileHandle = await window.showSaveFilePicker({
|
||||
suggestedName: suggestedName,
|
||||
types: [{
|
||||
description: 'Markdown files',
|
||||
accept: { 'text/markdown': ['.md', '.markdown'] }
|
||||
}]
|
||||
});
|
||||
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
|
||||
if (DEBUG) console.log(`File saved as: ${fileHandle.name}`);
|
||||
return fileHandle;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
if (DEBUG) console.log('Save cancelled by user');
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// Fallback: download as blob
|
||||
const blob = new Blob([content], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = suggestedName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
if (DEBUG) console.log(`File downloaded as: ${suggestedName}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open directory picker and handle selection
|
||||
*/
|
||||
async function openDirectory() {
|
||||
try {
|
||||
if (!('showDirectoryPicker' in window)) {
|
||||
throw new Error('The File System API is not supported in this browser.');
|
||||
}
|
||||
|
||||
directoryHandle = await window.showDirectoryPicker();
|
||||
if (DEBUG) console.log('Directory selected:', directoryHandle.name);
|
||||
|
||||
updateDirectoryStatus(directoryHandle.name);
|
||||
await readDirectory(directoryHandle);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
if (DEBUG) console.log('User cancelled the directory selection');
|
||||
} else {
|
||||
console.error('Error selecting directory:', error);
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI to show selected directory
|
||||
* @param {string} directoryName - Name of the selected directory
|
||||
*/
|
||||
function updateDirectoryStatus(directoryName) {
|
||||
const selectDirectoryBtn = document.getElementById('select-directory');
|
||||
if (selectDirectoryBtn) {
|
||||
selectDirectoryBtn.textContent = `Directory: ${directoryName}`;
|
||||
}
|
||||
|
||||
const refreshBtn = document.getElementById('refresh-directory');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Show new file button when directory is selected
|
||||
const newFileRootBtn = document.getElementById('new-file-root');
|
||||
if (newFileRootBtn) {
|
||||
newFileRootBtn.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read directory contents and build tree structure
|
||||
* @param {FileSystemDirectoryHandle} dirHandle - Directory handle
|
||||
* @param {Object} parentNode - Parent node in tree (for recursion)
|
||||
* @returns {Object} Statistics about the directory
|
||||
*/
|
||||
async function readDirectory(dirHandle, parentNode = null) {
|
||||
if (parentNode === null) {
|
||||
fileTree = {
|
||||
name: dirHandle.name,
|
||||
type: 'directory',
|
||||
handle: dirHandle,
|
||||
entries: {}
|
||||
};
|
||||
|
||||
const fileTreeElement = document.getElementById('file-tree');
|
||||
if (fileTreeElement) {
|
||||
fileTreeElement.innerHTML = '';
|
||||
}
|
||||
|
||||
parentNode = fileTree;
|
||||
}
|
||||
|
||||
try {
|
||||
let stats = { folderCount: 0, fileCount: 0 };
|
||||
|
||||
for await (const entry of dirHandle.values()) {
|
||||
if (entry.kind === 'file' && !entry.name.startsWith('_')) {
|
||||
parentNode.entries[entry.name] = {
|
||||
name: entry.name,
|
||||
type: 'file',
|
||||
handle: entry
|
||||
};
|
||||
stats.fileCount++;
|
||||
} else if (entry.kind === 'directory' && !entry.name.startsWith('_')) {
|
||||
const dirNode = {
|
||||
name: entry.name,
|
||||
type: 'directory',
|
||||
handle: entry,
|
||||
entries: {}
|
||||
};
|
||||
|
||||
parentNode.entries[entry.name] = dirNode;
|
||||
|
||||
const subStats = await readDirectory(entry, dirNode);
|
||||
stats.folderCount += subStats.folderCount + 1;
|
||||
stats.fileCount += subStats.fileCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (parentNode === fileTree) {
|
||||
renderFileTree();
|
||||
updateStatusCounts(stats.folderCount, stats.fileCount);
|
||||
}
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('Error reading directory:', error);
|
||||
return { folderCount: 0, fileCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a file by its path
|
||||
* @param {string} filePath - Path of the file to save
|
||||
* @returns {Promise<boolean>} Whether save was successful
|
||||
*/
|
||||
async function saveFile(filePath) {
|
||||
if (!filePath && currentFileHandle) {
|
||||
filePath = currentFileHandle.name;
|
||||
} else if (!filePath) {
|
||||
alert('No file is currently open');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const editorInstance = editorInstances.get(filePath);
|
||||
if (!editorInstance) {
|
||||
throw new Error('No editor instance found for this file');
|
||||
}
|
||||
|
||||
if (!editorInstance.isDirty) {
|
||||
if (DEBUG) console.log(`File ${filePath} is not dirty, skipping save`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const fileHandle = editorInstance.fileHandle;
|
||||
if (!fileHandle) {
|
||||
throw new Error('No file handle available for this file');
|
||||
}
|
||||
|
||||
// Check for external modifications
|
||||
const file = await fileHandle.getFile();
|
||||
const currentLastModified = file.lastModified;
|
||||
const storedLastModified = editorInstance.lastModified;
|
||||
|
||||
if (storedLastModified && currentLastModified !== storedLastModified) {
|
||||
const confirmSave = confirm(
|
||||
'Warning: This file has been modified outside of the application since you opened it. ' +
|
||||
'Saving will overwrite those changes. Do you want to continue?'
|
||||
);
|
||||
|
||||
if (!confirmSave) {
|
||||
if (DEBUG) console.log('Save aborted by user due to external file modifications');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get markdown content from editor
|
||||
const markdownContent = editorInstance.editor.getMarkdown();
|
||||
|
||||
// Get front matter from textarea
|
||||
let frontMatterData = {};
|
||||
if (editorInstance.frontMatterTextarea) {
|
||||
const frontMatterText = editorInstance.frontMatterTextarea.value.trim();
|
||||
if (frontMatterText) {
|
||||
try {
|
||||
const yamlContent = `---\n${frontMatterText}\n---\n`;
|
||||
const parsed = parseFrontMatter(yamlContent);
|
||||
frontMatterData = parsed.data;
|
||||
} catch (error) {
|
||||
console.error('Error parsing front matter:', error);
|
||||
throw new Error(`Invalid YAML front matter: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply before save hooks
|
||||
frontMatterData = await applyBeforeSaveHooks(frontMatterData, markdownContent, fileHandle);
|
||||
|
||||
// Combine front matter with markdown
|
||||
const finalContent = frontMatterData && Object.keys(frontMatterData).length > 0
|
||||
? stringifyFrontMatter(markdownContent, frontMatterData)
|
||||
: markdownContent;
|
||||
|
||||
// Write to file
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(finalContent);
|
||||
await writable.close();
|
||||
|
||||
// Update state
|
||||
const updatedFile = await fileHandle.getFile();
|
||||
editorInstance.lastModified = updatedFile.lastModified;
|
||||
editorInstance.isDirty = false;
|
||||
updateFileDirtyStatus(filePath, false);
|
||||
updateUnsavedCount();
|
||||
|
||||
if (editorInstance.saveButton) {
|
||||
editorInstance.saveButton.disabled = true;
|
||||
}
|
||||
|
||||
if (DEBUG) console.log(`File ${filePath} saved successfully!`);
|
||||
|
||||
await applyAfterSaveHooks(frontMatterData, markdownContent, fileHandle);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error saving file ${filePath}:`, error);
|
||||
alert(`Error saving file: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all files with unsaved changes
|
||||
* @returns {Promise<{saved: number, failed: number}>}
|
||||
*/
|
||||
async function saveAllFiles() {
|
||||
let saved = 0;
|
||||
let failed = 0;
|
||||
|
||||
const dirtyFiles = [];
|
||||
editorInstances.forEach((instance, filePath) => {
|
||||
if (instance.isDirty) {
|
||||
dirtyFiles.push(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
if (dirtyFiles.length === 0) {
|
||||
if (DEBUG) console.log('No files with unsaved changes');
|
||||
return { saved, failed };
|
||||
}
|
||||
|
||||
for (const filePath of dirtyFiles) {
|
||||
try {
|
||||
const success = await saveFile(filePath);
|
||||
if (success) {
|
||||
saved++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error saving file ${filePath}:`, error);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
if (DEBUG) console.log(`All ${saved} files saved successfully`);
|
||||
} else {
|
||||
if (DEBUG) console.log(`Saved ${saved} files, ${failed} files failed to save`);
|
||||
}
|
||||
|
||||
return { saved, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload file from disk
|
||||
* @param {string} filePath - Path of file to reload
|
||||
* @returns {Promise<boolean>} Whether reload was successful
|
||||
*/
|
||||
async function reloadFileFromDisk(filePath) {
|
||||
try {
|
||||
const editorInstance = editorInstances.get(filePath);
|
||||
if (!editorInstance) {
|
||||
throw new Error('No editor instance found for this file');
|
||||
}
|
||||
|
||||
if (editorInstance.isDirty) {
|
||||
const confirmReload = confirm(
|
||||
'This file has unsaved changes. Reloading will discard all changes. ' +
|
||||
'Do you want to continue?'
|
||||
);
|
||||
|
||||
if (!confirmReload) {
|
||||
if (DEBUG) console.log('Reload cancelled by user');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const fileHandle = editorInstance.fileHandle;
|
||||
if (!fileHandle) {
|
||||
throw new Error('No file handle available for this file');
|
||||
}
|
||||
|
||||
const file = await fileHandle.getFile();
|
||||
const fileContent = await file.text();
|
||||
|
||||
editorInstance.lastModified = file.lastModified;
|
||||
|
||||
if (filePath.endsWith('.md') || filePath.endsWith('.markdown')) {
|
||||
const parsed = parseFrontMatter(fileContent);
|
||||
|
||||
if (editorInstance.frontMatterTextarea) {
|
||||
const frontMatterYaml = stringifyFrontMatterToTextarea(parsed.data);
|
||||
editorInstance.frontMatterTextarea.value = frontMatterYaml;
|
||||
}
|
||||
|
||||
let currentScrollTop = 0;
|
||||
try {
|
||||
currentScrollTop = editorInstance.editor.getScrollTop();
|
||||
} catch (error) {
|
||||
if (DEBUG) console.debug('Could not get scroll position:', error);
|
||||
}
|
||||
|
||||
editorInstance.editor.setMarkdown(parsed.content);
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
editorInstance.editor.setScrollTop(currentScrollTop);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.debug('Could not restore scroll position:', error);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
if (editorInstance.tocContainer && window.updateToc) {
|
||||
try {
|
||||
window.updateToc(parsed.content, editorInstance.tocContainer, editorInstance.editor, tocMaxDepth);
|
||||
} catch (error) {
|
||||
console.error('Error updating TOC during reload:', error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
editorInstance.editor.setMarkdown(fileContent);
|
||||
}
|
||||
|
||||
editorInstance.isDirty = false;
|
||||
updateFileDirtyStatus(filePath, false);
|
||||
updateUnsavedCount();
|
||||
|
||||
if (editorInstance.saveButton) {
|
||||
editorInstance.saveButton.disabled = true;
|
||||
}
|
||||
|
||||
if (DEBUG) console.log(`File ${filePath} reloaded successfully from disk!`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error reloading file ${filePath}:`, error);
|
||||
alert(`Error reloading file: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Before save hook - apply modifications before saving
|
||||
*/
|
||||
async function applyBeforeSaveHooks(frontMatter, markdownContent, fileHandle) {
|
||||
frontMatter.lastModified = new Date().toISOString();
|
||||
|
||||
if (!frontMatter.title) {
|
||||
const firstHeading = markdownContent.match(/^#\s+(.+)$/m);
|
||||
if (firstHeading) {
|
||||
frontMatter.title = firstHeading[1];
|
||||
}
|
||||
}
|
||||
|
||||
const customTags = (markdownContent.match(/<(deliverable|meeting|report|trkno)>/g) || []).length;
|
||||
if (customTags > 0) {
|
||||
frontMatter.customTagCount = customTags;
|
||||
}
|
||||
|
||||
return frontMatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* After save hook - perform actions after saving
|
||||
*/
|
||||
async function applyAfterSaveHooks(frontMatter, markdownContent, fileHandle) {
|
||||
const tags = ['deliverable', 'meeting', 'report', 'trkno'];
|
||||
const preservedTags = tags.filter(tag => markdownContent.includes(`<${tag}>`));
|
||||
if (preservedTags.length > 0) {
|
||||
if (DEBUG) console.log(`Preserved custom tags: ${preservedTags.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh directory from disk without losing unsaved work
|
||||
*/
|
||||
async function refreshDirectory() {
|
||||
if (!directoryHandle) {
|
||||
if (DEBUG) console.log('No directory selected, cannot refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get active file path from DOM before refresh
|
||||
const activeFileEl = document.querySelector('.file-item.active-file');
|
||||
const activeFilePath = activeFileEl ? activeFileEl.dataset.path : null;
|
||||
|
||||
// Get dirty files from editorInstances
|
||||
const dirtyFiles = new Set();
|
||||
editorInstances.forEach((instance, filePath) => {
|
||||
if (instance.isDirty) {
|
||||
dirtyFiles.add(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
// Re-read directory (calls renderFileTree at the end)
|
||||
await readDirectory(directoryHandle);
|
||||
|
||||
// Restore active file state
|
||||
if (activeFilePath) {
|
||||
const activeElement = document.querySelector(`.file-item[data-path="${activeFilePath}"]`);
|
||||
if (activeElement) {
|
||||
activeElement.classList.add('active-file');
|
||||
}
|
||||
}
|
||||
|
||||
// Restore dirty indicators
|
||||
dirtyFiles.forEach(filePath => {
|
||||
updateFileDirtyStatus(filePath, true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring files for external changes
|
||||
*/
|
||||
function startFileChangeMonitoring() {
|
||||
setInterval(async () => {
|
||||
for (const [filePath, editorInstance] of editorInstances) {
|
||||
try {
|
||||
const fileHandle = editorInstance.fileHandle;
|
||||
if (!fileHandle) continue;
|
||||
|
||||
const file = await fileHandle.getFile();
|
||||
const currentLastModified = file.lastModified;
|
||||
const storedLastModified = editorInstance.lastModified;
|
||||
|
||||
if (storedLastModified && currentLastModified !== storedLastModified) {
|
||||
if (DEBUG) console.log(`File ${filePath} changed externally`);
|
||||
|
||||
const action = confirm(
|
||||
`File "${filePath}" has been modified by another application.\n\n` +
|
||||
'Click OK to reload from disk (discards unsaved changes)\n' +
|
||||
'Click Cancel to keep current version'
|
||||
);
|
||||
|
||||
if (action) {
|
||||
await reloadFileFromDisk(filePath);
|
||||
} else {
|
||||
editorInstance.lastModified = currentLastModified;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (DEBUG) console.debug(`Error checking file ${filePath}:`, error.message);
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
731
mdedit/js/file-tree.js
Normal file
731
mdedit/js/file-tree.js
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
/**
|
||||
* File tree rendering and navigation
|
||||
*/
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the file tree in the UI
|
||||
*/
|
||||
/**
|
||||
* Create action buttons for file/directory items
|
||||
* @param {string} filePath - Full path of the file/dir
|
||||
* @param {string} type - 'file' or 'directory'
|
||||
*/
|
||||
function createActionButtons(filePath, type) {
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'tree-actions';
|
||||
|
||||
if (type === 'directory') {
|
||||
// Directory: + (new file) + ✕ (delete)
|
||||
const newFileBtn = document.createElement('button');
|
||||
newFileBtn.className = 'tree-btn';
|
||||
newFileBtn.setAttribute('title', 'New file');
|
||||
newFileBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>';
|
||||
newFileBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
createNewFile(filePath);
|
||||
};
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'tree-btn tree-btn--danger';
|
||||
deleteBtn.setAttribute('title', 'Delete');
|
||||
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||||
deleteBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
deleteEntry(filePath, true);
|
||||
};
|
||||
|
||||
actionsDiv.appendChild(newFileBtn);
|
||||
actionsDiv.appendChild(deleteBtn);
|
||||
} else {
|
||||
// File: ✎ (rename) + ✕ (delete)
|
||||
const renameBtn = document.createElement('button');
|
||||
renameBtn.className = 'tree-btn';
|
||||
renameBtn.setAttribute('title', 'Rename');
|
||||
renameBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
|
||||
renameBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
renameEntry(filePath, false);
|
||||
};
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'tree-btn tree-btn--danger';
|
||||
deleteBtn.setAttribute('title', 'Delete');
|
||||
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||||
deleteBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
deleteEntry(filePath, false);
|
||||
};
|
||||
|
||||
actionsDiv.appendChild(renameBtn);
|
||||
actionsDiv.appendChild(deleteBtn);
|
||||
}
|
||||
|
||||
return actionsDiv;
|
||||
}
|
||||
|
||||
function renderFileTree() {
|
||||
const fileTreeElement = document.getElementById('file-tree');
|
||||
if (!fileTreeElement) return;
|
||||
|
||||
fileTreeElement.innerHTML = '';
|
||||
|
||||
// Always show scratchpad at top
|
||||
const scratchpadElement = document.createElement('div');
|
||||
scratchpadElement.className = 'file-item px-2 py-1 cursor-pointer rounded whitespace-nowrap overflow-hidden hover:bg-gray-100 dark:hover:bg-gray-800 border-b border-gray-200 dark:border-gray-700 mb-2';
|
||||
scratchpadElement.dataset.type = 'file';
|
||||
scratchpadElement.dataset.path = SCRATCHPAD_ID;
|
||||
scratchpadElement.dataset.name = 'Scratchpad';
|
||||
scratchpadElement.innerHTML = '<div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick editing (no file)</div>';
|
||||
|
||||
scratchpadElement.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openScratchpad();
|
||||
|
||||
// Update active state
|
||||
document.querySelectorAll('.file-item').forEach(el => el.classList.remove('active-file'));
|
||||
scratchpadElement.classList.add('active-file');
|
||||
});
|
||||
|
||||
fileTreeElement.appendChild(scratchpadElement);
|
||||
|
||||
function createFileTreeHTML(directory, parentElement, path = '') {
|
||||
if (!directory || !directory.entries) return;
|
||||
|
||||
// Sort entries: files first, then directories, alphabetically
|
||||
const sortedEntries = Object.entries(directory.entries).sort((a, b) => {
|
||||
const [nameA, itemA] = a;
|
||||
const [nameB, itemB] = b;
|
||||
|
||||
if (itemA.type !== itemB.type) {
|
||||
return itemA.type === 'file' ? -1 : 1;
|
||||
}
|
||||
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
for (const [name, item] of sortedEntries) {
|
||||
if (item.type === 'directory') {
|
||||
const dirElement = document.createElement('div');
|
||||
dirElement.className = 'directory-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800 collapsed';
|
||||
dirElement.dataset.type = 'directory';
|
||||
const currentPath = path ? `${path}/${name}` : name;
|
||||
dirElement.dataset.path = currentPath;
|
||||
|
||||
const dirIcon = document.createElement('span');
|
||||
dirIcon.className = 'dir-icon mr-1';
|
||||
dirIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>';
|
||||
|
||||
const dirName = document.createElement('span');
|
||||
dirName.textContent = `📁 ${name}`;
|
||||
|
||||
const dirLabel = document.createElement('span');
|
||||
dirLabel.className = 'tree-row__label';
|
||||
dirLabel.appendChild(dirIcon);
|
||||
dirLabel.appendChild(dirName);
|
||||
|
||||
const dirActions = createActionButtons(currentPath, 'directory');
|
||||
|
||||
dirElement.appendChild(dirLabel);
|
||||
dirElement.appendChild(dirActions);
|
||||
parentElement.appendChild(dirElement);
|
||||
|
||||
const contentsElement = document.createElement('div');
|
||||
contentsElement.className = 'directory-contents ml-4';
|
||||
contentsElement.style.display = 'none';
|
||||
parentElement.appendChild(contentsElement);
|
||||
|
||||
dirElement.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
dirElement.classList.toggle('collapsed');
|
||||
|
||||
const contents = dirElement.nextElementSibling;
|
||||
if (contents && contents.classList.contains('directory-contents')) {
|
||||
contents.style.display = dirElement.classList.contains('collapsed') ? 'none' : 'block';
|
||||
}
|
||||
});
|
||||
|
||||
createFileTreeHTML(item, contentsElement, currentPath);
|
||||
} else if (item.type === 'file') {
|
||||
const fileElement = document.createElement('div');
|
||||
fileElement.className = 'file-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800';
|
||||
fileElement.dataset.type = 'file';
|
||||
const filePath = path ? `${path}/${name}` : name;
|
||||
fileElement.dataset.path = filePath;
|
||||
fileElement.dataset.name = name;
|
||||
|
||||
const fileIcon = getFileTypeIcon(name);
|
||||
|
||||
let fileNameDisplay;
|
||||
const parsed = zddc.parseFilename(name);
|
||||
if (parsed && parsed.valid) {
|
||||
// Strip extension from title for display (it's already in the icon)
|
||||
const titleDisplay = parsed.title;
|
||||
const metaDisplay = `${parsed.trackingNumber}_${parsed.revision} (${parsed.status})`;
|
||||
fileNameDisplay = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
|
||||
} else if (name.includes(' - ')) {
|
||||
// Fallback: simple split for files with ' - ' but not fully ZDDC-compliant
|
||||
const dashIdx = name.lastIndexOf(' - ');
|
||||
const secondary = name.substring(0, dashIdx);
|
||||
const primary = name.substring(dashIdx + 3).replace(/\.[^.]+$/, '');
|
||||
fileNameDisplay = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
|
||||
} else {
|
||||
fileNameDisplay = `<span>${fileIcon} ${name}</span>`;
|
||||
}
|
||||
|
||||
const fileLabel = document.createElement('span');
|
||||
fileLabel.className = 'tree-row__label';
|
||||
fileLabel.innerHTML = fileNameDisplay;
|
||||
|
||||
const fileActions = createActionButtons(filePath, 'file');
|
||||
|
||||
fileElement.innerHTML = '';
|
||||
fileElement.appendChild(fileLabel);
|
||||
fileElement.appendChild(fileActions);
|
||||
|
||||
fileElement.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
handleFileClick(item.handle, filePath, fileElement);
|
||||
});
|
||||
|
||||
parentElement.appendChild(fileElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createFileTreeHTML(fileTree, fileTreeElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click on a file in the file tree
|
||||
* @param {FileSystemFileHandle} fileHandle - The file handle
|
||||
* @param {string} filePath - Path of the file
|
||||
* @param {HTMLElement} fileElement - The clicked element
|
||||
*/
|
||||
async function handleFileClick(fileHandle, filePath, fileElement) {
|
||||
try {
|
||||
currentFileHandle = fileHandle;
|
||||
|
||||
// Remove active class from all file items
|
||||
const allFileItems = document.querySelectorAll('.file-item');
|
||||
allFileItems.forEach(item => {
|
||||
item.classList.remove('active-file');
|
||||
item.style.backgroundColor = '';
|
||||
item.style.color = '';
|
||||
});
|
||||
|
||||
// Add active class to clicked file
|
||||
fileElement.classList.add('active-file');
|
||||
fileElement.style.backgroundColor = '#3b82f6';
|
||||
fileElement.style.color = 'white';
|
||||
|
||||
await displayFileContent(fileHandle, filePath);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error handling file click:', error);
|
||||
alert(`Error opening file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display file content in main area
|
||||
* @param {FileSystemFileHandle} fileHandle - File handle
|
||||
* @param {string} filePath - Path of the file
|
||||
*/
|
||||
async function displayFileContent(fileHandle, filePath) {
|
||||
try {
|
||||
currentFileHandle = fileHandle;
|
||||
|
||||
const file = await fileHandle.getFile();
|
||||
const fileName = file.name;
|
||||
const lastModified = file.lastModified;
|
||||
|
||||
document.getElementById('welcome-screen').classList.add('hidden');
|
||||
document.getElementById('content-container').classList.remove('hidden');
|
||||
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
|
||||
const isImage = imageExtensions.some(ext => fileName.toLowerCase().endsWith(ext));
|
||||
|
||||
const isHtml = fileName.toLowerCase().endsWith('.html') || fileName.toLowerCase().endsWith('.htm');
|
||||
const isDocx = fileName.toLowerCase().endsWith('.docx');
|
||||
const isXlsx = fileName.toLowerCase().endsWith('.xlsx') || fileName.toLowerCase().endsWith('.xls');
|
||||
const isPdf = fileName.toLowerCase().endsWith('.pdf');
|
||||
|
||||
if (isImage) {
|
||||
displayImagePreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isHtml) {
|
||||
displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isDocx) {
|
||||
displayDocxPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isXlsx) {
|
||||
displayXlsxPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isPdf) {
|
||||
displayPdfPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else {
|
||||
const content = await file.text();
|
||||
|
||||
if (fileName.toLowerCase().endsWith('.md')) {
|
||||
initializeEditor(content, true, filePath, fileName, fileHandle, lastModified);
|
||||
} else {
|
||||
initializeEditor(content, false, filePath, fileName, fileHandle, lastModified);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error displaying file content:', error);
|
||||
alert(`Error opening file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display image preview
|
||||
*/
|
||||
async function displayImagePreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) {
|
||||
alert('Error: content-container element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(container => {
|
||||
container.style.display = 'none';
|
||||
});
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existingInstance = editorInstances.get(filePath);
|
||||
if (existingInstance.fileViewContainer) {
|
||||
existingInstance.fileViewContainer.style.display = 'flex';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const imageContainer = document.createElement('div');
|
||||
imageContainer.className = 'image-preview-container flex-1 overflow-auto p-4';
|
||||
|
||||
const imageElement = document.createElement('img');
|
||||
imageElement.className = 'image-preview';
|
||||
imageElement.alt = fileName;
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
imageElement.src = objectUrl;
|
||||
|
||||
imageContainer.appendChild(imageElement);
|
||||
fileViewContainer.appendChild(imageContainer);
|
||||
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
const instanceData = {
|
||||
fileViewContainer: fileViewContainer,
|
||||
fileHandle: fileHandle,
|
||||
lastModified: lastModified,
|
||||
isDirty: false,
|
||||
objectUrl: objectUrl
|
||||
};
|
||||
|
||||
editorInstances.set(filePath, instanceData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display HTML preview in sandboxed iframe
|
||||
*/
|
||||
async function displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) {
|
||||
alert('Error: content-container element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(container => {
|
||||
container.style.display = 'none';
|
||||
});
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existingInstance = editorInstances.get(filePath);
|
||||
if (existingInstance.fileViewContainer) {
|
||||
existingInstance.fileViewContainer.style.display = 'flex';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const htmlContent = await file.text();
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const htmlContainer = document.createElement('div');
|
||||
htmlContainer.className = 'html-preview-container flex-1 overflow-hidden';
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'html-preview-iframe w-full h-full border-0';
|
||||
|
||||
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-modals');
|
||||
iframe.setAttribute('loading', 'lazy');
|
||||
|
||||
iframe.srcdoc = htmlContent;
|
||||
|
||||
htmlContainer.appendChild(iframe);
|
||||
fileViewContainer.appendChild(htmlContainer);
|
||||
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
const instanceData = {
|
||||
fileViewContainer: fileViewContainer,
|
||||
fileHandle: fileHandle,
|
||||
lastModified: lastModified,
|
||||
isDirty: false,
|
||||
iframe: iframe
|
||||
};
|
||||
|
||||
editorInstances.set(filePath, instanceData);
|
||||
|
||||
iframe.addEventListener('load', () => {
|
||||
try {
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
if (iframeDoc) {
|
||||
iframeDoc.addEventListener('click', function (e) {
|
||||
const link = e.target.closest('a');
|
||||
if (link && link.getAttribute('href')) {
|
||||
const href = link.getAttribute('href');
|
||||
if (href.startsWith('#')) {
|
||||
e.preventDefault();
|
||||
const targetId = href.substring(1);
|
||||
const targetElement = iframeDoc.getElementById(targetId);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (DEBUG) console.log('Cannot access iframe content for navigation handling:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display DOCX preview in main content area
|
||||
*/
|
||||
async function displayDocxPreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) {
|
||||
alert('Error: content-container element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(container => {
|
||||
container.style.display = 'none';
|
||||
});
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existingInstance = editorInstances.get(filePath);
|
||||
if (existingInstance.fileViewContainer) {
|
||||
existingInstance.fileViewContainer.style.display = 'flex';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const docxContainer = document.createElement('div');
|
||||
docxContainer.className = 'flex-1 overflow-auto p-4';
|
||||
docxContainer.innerHTML = '<div style="text-align:center;padding:2rem;color:#666;">Loading preview...</div>';
|
||||
fileViewContainer.appendChild(docxContainer);
|
||||
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
const instanceData = {
|
||||
fileViewContainer: fileViewContainer,
|
||||
fileHandle: fileHandle,
|
||||
lastModified: lastModified,
|
||||
isDirty: false
|
||||
};
|
||||
editorInstances.set(filePath, instanceData);
|
||||
|
||||
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 arrayBuffer = await file.arrayBuffer();
|
||||
docxContainer.innerHTML = '';
|
||||
await window.docx.renderAsync(arrayBuffer, docxContainer);
|
||||
} catch (err) {
|
||||
console.error('Error rendering DOCX:', err);
|
||||
docxContainer.innerHTML = `<div style="text-align:center;padding:2rem;color:#c00;">Error rendering DOCX: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display XLSX/XLS preview in main content area
|
||||
*/
|
||||
async function displayXlsxPreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) {
|
||||
alert('Error: content-container element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(container => {
|
||||
container.style.display = 'none';
|
||||
});
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existingInstance = editorInstances.get(filePath);
|
||||
if (existingInstance.fileViewContainer) {
|
||||
existingInstance.fileViewContainer.style.display = 'flex';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const xlsxContainer = document.createElement('div');
|
||||
xlsxContainer.className = 'flex-1 overflow-auto';
|
||||
xlsxContainer.innerHTML = '<div style="text-align:center;padding:2rem;color:#666;">Loading preview...</div>';
|
||||
fileViewContainer.appendChild(xlsxContainer);
|
||||
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
const instanceData = {
|
||||
fileViewContainer: fileViewContainer,
|
||||
fileHandle: fileHandle,
|
||||
lastModified: lastModified,
|
||||
isDirty: false
|
||||
};
|
||||
editorInstances.set(filePath, instanceData);
|
||||
|
||||
try {
|
||||
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
|
||||
xlsxContainer.innerHTML = '';
|
||||
|
||||
if (workbook.SheetNames.length > 1) {
|
||||
const tabs = document.createElement('div');
|
||||
tabs.style.cssText = 'display:flex;gap:0;border-bottom:1px solid #ddd;background:#f5f5f5;';
|
||||
const tableArea = document.createElement('div');
|
||||
tableArea.className = 'flex-1 overflow-auto';
|
||||
|
||||
workbook.SheetNames.forEach((name, i) => {
|
||||
const tab = document.createElement('button');
|
||||
tab.textContent = name;
|
||||
tab.style.cssText = 'padding:0.4rem 1rem;cursor:pointer;border:1px solid transparent;border-bottom:none;font-size:0.85rem;background:transparent;';
|
||||
if (i === 0) tab.style.cssText += 'background:white;border-color:#ddd;border-bottom-color:white;margin-bottom:-1px;font-weight:500;';
|
||||
tab.onclick = () => {
|
||||
tabs.querySelectorAll('button').forEach(t => { t.style.background = 'transparent'; t.style.borderColor = 'transparent'; t.style.fontWeight = 'normal'; });
|
||||
tab.style.cssText = 'padding:0.4rem 1rem;cursor:pointer;border:1px solid #ddd;border-bottom-color:white;font-size:0.85rem;background:white;margin-bottom:-1px;font-weight:500;';
|
||||
renderXlsxSheet(workbook, name, tableArea);
|
||||
};
|
||||
tabs.appendChild(tab);
|
||||
});
|
||||
|
||||
xlsxContainer.appendChild(tabs);
|
||||
xlsxContainer.appendChild(tableArea);
|
||||
renderXlsxSheet(workbook, workbook.SheetNames[0], tableArea);
|
||||
} else {
|
||||
renderXlsxSheet(workbook, workbook.SheetNames[0], xlsxContainer);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error rendering XLSX:', err);
|
||||
xlsxContainer.innerHTML = `<div style="text-align:center;padding:2rem;color:#c00;">Error rendering spreadsheet: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single XLSX sheet as an HTML table
|
||||
*/
|
||||
function renderXlsxSheet(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.style.cssText = 'border-collapse:collapse;width:100%;font-size:0.85rem;';
|
||||
table.querySelectorAll('th,td').forEach(cell => {
|
||||
cell.style.cssText = 'border:1px solid #ddd;padding:0.35rem 0.5rem;text-align:left;white-space:nowrap;';
|
||||
});
|
||||
table.querySelectorAll('th').forEach(th => {
|
||||
th.style.background = '#f0f0f0';
|
||||
th.style.fontWeight = '600';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display PDF preview using browser's built-in PDF viewer
|
||||
*/
|
||||
async function displayPdfPreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) return;
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(container => {
|
||||
container.style.display = 'none';
|
||||
});
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existingInstance = editorInstances.get(filePath);
|
||||
if (existingInstance.fileViewContainer) {
|
||||
existingInstance.fileViewContainer.style.display = 'flex';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName;
|
||||
fileHeader.appendChild(fileTitle);
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const pdfContainer = document.createElement('div');
|
||||
pdfContainer.className = 'flex-1 overflow-hidden';
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'w-full h-full border-0';
|
||||
iframe.src = objectUrl;
|
||||
iframe.setAttribute('title', fileName);
|
||||
|
||||
pdfContainer.appendChild(iframe);
|
||||
fileViewContainer.appendChild(pdfContainer);
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
editorInstances.set(filePath, {
|
||||
fileViewContainer,
|
||||
fileHandle,
|
||||
lastModified,
|
||||
isDirty: false,
|
||||
objectUrl
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status bar counts
|
||||
*/
|
||||
function updateStatusCounts(folderCount, fileCount) {
|
||||
const folderCountElement = document.getElementById('folder-count');
|
||||
const fileCountElement = document.getElementById('file-count');
|
||||
|
||||
if (folderCountElement) {
|
||||
folderCountElement.textContent = `${folderCount} folder${folderCount !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
if (fileCountElement) {
|
||||
fileCountElement.textContent = `${fileCount} file${fileCount !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
updateUnsavedCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update unsaved count in status bar
|
||||
*/
|
||||
function updateUnsavedCount() {
|
||||
const unsavedCountElement = document.getElementById('unsaved-count');
|
||||
if (!unsavedCountElement) return;
|
||||
|
||||
let dirtyCount = 0;
|
||||
editorInstances.forEach(instance => {
|
||||
if (instance.isDirty) {
|
||||
dirtyCount++;
|
||||
}
|
||||
});
|
||||
|
||||
unsavedCountElement.textContent = `${dirtyCount} unsaved`;
|
||||
|
||||
if (dirtyCount > 0) {
|
||||
unsavedCountElement.classList.add('text-amber-500', 'font-medium');
|
||||
} else {
|
||||
unsavedCountElement.classList.remove('text-amber-500', 'font-medium');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update file dirty status indicator in tree
|
||||
*/
|
||||
function updateFileDirtyStatus(filePath, isDirty) {
|
||||
const fileElement = document.querySelector(`.file-item[data-path="${filePath}"]`);
|
||||
if (!fileElement) return;
|
||||
|
||||
if (isDirty) {
|
||||
if (!fileElement.querySelector('.dirty-indicator')) {
|
||||
const indicator = document.createElement('span');
|
||||
indicator.className = 'dirty-indicator ml-1 text-amber-500 font-bold';
|
||||
indicator.textContent = '●';
|
||||
fileElement.appendChild(indicator);
|
||||
}
|
||||
fileElement.classList.add('is-dirty');
|
||||
} else {
|
||||
const indicator = fileElement.querySelector('.dirty-indicator');
|
||||
if (indicator) {
|
||||
fileElement.removeChild(indicator);
|
||||
}
|
||||
fileElement.classList.remove('is-dirty');
|
||||
}
|
||||
}
|
||||
106
mdedit/js/front-matter.js
Normal file
106
mdedit/js/front-matter.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* YAML front matter parsing and stringification
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse YAML front matter from markdown content
|
||||
* @param {string} content - Full markdown content with potential front matter
|
||||
* @returns {{data: Object, content: string}} Parsed front matter data and remaining content
|
||||
*/
|
||||
function parseFrontMatter(content) {
|
||||
if (!content || !content.startsWith('---\n')) {
|
||||
return {
|
||||
data: {},
|
||||
content: content || ''
|
||||
};
|
||||
}
|
||||
|
||||
const endMatch = content.indexOf('\n---\n', 4);
|
||||
if (endMatch === -1) {
|
||||
return {
|
||||
data: {},
|
||||
content: content
|
||||
};
|
||||
}
|
||||
|
||||
const frontMatterText = content.substring(4, endMatch);
|
||||
const markdownBody = content.substring(endMatch + 5);
|
||||
|
||||
// Parse YAML front matter (basic key: value parsing)
|
||||
const frontMatterData = {};
|
||||
const lines = frontMatterText.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) continue;
|
||||
|
||||
const colonIndex = trimmedLine.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = trimmedLine.substring(0, colonIndex).trim();
|
||||
let value = trimmedLine.substring(colonIndex + 1).trim();
|
||||
|
||||
// Remove quotes
|
||||
value = value.replace(/^["']|["']$/g, '');
|
||||
|
||||
// Handle arrays (basic support for [item1, item2])
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
value = value.slice(1, -1).split(',').map(item => item.trim().replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
|
||||
frontMatterData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: frontMatterData,
|
||||
content: markdownBody
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify front matter data and combine with markdown content
|
||||
* @param {string} content - Markdown content
|
||||
* @param {Object} data - Front matter data object
|
||||
* @returns {string} Combined YAML front matter and markdown
|
||||
*/
|
||||
function stringifyFrontMatter(content, data) {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let yamlString = '---\n';
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (Array.isArray(value)) {
|
||||
yamlString += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`;
|
||||
} else {
|
||||
yamlString += `${key}: "${value}"\n`;
|
||||
}
|
||||
}
|
||||
|
||||
yamlString += '---\n';
|
||||
|
||||
return yamlString + content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert front matter data to YAML string for textarea display (without delimiters)
|
||||
* @param {Object} data - Front matter data
|
||||
* @returns {string} YAML string for textarea
|
||||
*/
|
||||
function stringifyFrontMatterToTextarea(data) {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let yamlString = '';
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (Array.isArray(value)) {
|
||||
yamlString += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`;
|
||||
} else {
|
||||
yamlString += `${key}: "${value}"\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return yamlString.trim();
|
||||
}
|
||||
40
mdedit/js/main.js
Normal file
40
mdedit/js/main.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Application initialization
|
||||
*/
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Check File System API availability and update UI
|
||||
initializeApiAvailability();
|
||||
|
||||
setupEventListeners();
|
||||
initializeFileNavResizer();
|
||||
setupTocDepthSelector();
|
||||
startFileChangeMonitoring();
|
||||
|
||||
// Show scratchpad in file tree on startup
|
||||
renderFileTree();
|
||||
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize UI based on File System API availability
|
||||
*/
|
||||
function initializeApiAvailability() {
|
||||
const selectDirectoryBtn = document.getElementById('select-directory');
|
||||
const welcomeHint = document.getElementById('welcome-hint');
|
||||
const welcomeFirefox = document.getElementById('welcome-firefox');
|
||||
|
||||
if (!hasFileSystemAccess) {
|
||||
// Disable file system buttons in Firefox and other unsupported browsers
|
||||
if (selectDirectoryBtn) {
|
||||
selectDirectoryBtn.disabled = true;
|
||||
selectDirectoryBtn.title = 'File System API not supported in this browser';
|
||||
}
|
||||
// Show Firefox warning, hide normal hint
|
||||
if (welcomeHint) welcomeHint.classList.add('hidden');
|
||||
if (welcomeFirefox) welcomeFirefox.classList.remove('hidden');
|
||||
|
||||
}
|
||||
}
|
||||
93
mdedit/js/resizer.js
Normal file
93
mdedit/js/resizer.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Pane resizing functionality
|
||||
*/
|
||||
|
||||
/**
|
||||
* Make an element resizable by dragging its resizer
|
||||
* @param {HTMLElement} resizer - The resizer element
|
||||
* @param {HTMLElement} pane - The pane to resize
|
||||
*/
|
||||
function makeResizable(resizer, pane) {
|
||||
const initialWidth = pane.offsetWidth;
|
||||
|
||||
let x = 0;
|
||||
let paneWidth = initialWidth;
|
||||
|
||||
const mouseDownHandler = function (e) {
|
||||
x = e.clientX;
|
||||
paneWidth = pane.offsetWidth;
|
||||
|
||||
document.addEventListener('mousemove', mouseMoveHandler);
|
||||
document.addEventListener('mouseup', mouseUpHandler);
|
||||
|
||||
resizer.classList.add('active');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const mouseMoveHandler = function (e) {
|
||||
const dx = e.clientX - x;
|
||||
const newWidth = Math.max(150, paneWidth + dx);
|
||||
|
||||
pane.style.width = `${newWidth}px`;
|
||||
};
|
||||
|
||||
const mouseUpHandler = function () {
|
||||
document.removeEventListener('mousemove', mouseMoveHandler);
|
||||
document.removeEventListener('mouseup', mouseUpHandler);
|
||||
|
||||
resizer.classList.remove('active');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
resizer.addEventListener('mousedown', mouseDownHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the file navigation pane resizer
|
||||
*/
|
||||
function initializeFileNavResizer() {
|
||||
const fileNavResizer = document.querySelector('.pane-resizer[data-resizer-for="file-nav"]');
|
||||
|
||||
if (fileNavResizer && !fileNavResizer.hasAttribute('data-resizer-initialized')) {
|
||||
fileNavResizer.setAttribute('data-resizer-initialized', 'true');
|
||||
|
||||
let x = 0;
|
||||
let navWidth = 0;
|
||||
|
||||
const mouseDownHandler = function (e) {
|
||||
x = e.clientX;
|
||||
|
||||
const navPane = document.getElementById('file-nav');
|
||||
navWidth = navPane.getBoundingClientRect().width;
|
||||
|
||||
document.addEventListener('mousemove', mouseMoveHandler);
|
||||
document.addEventListener('mouseup', mouseUpHandler);
|
||||
|
||||
fileNavResizer.classList.add('bg-blue-500');
|
||||
};
|
||||
|
||||
const mouseMoveHandler = function (e) {
|
||||
const dx = e.clientX - x;
|
||||
|
||||
const navPane = document.getElementById('file-nav');
|
||||
|
||||
const newWidth = navWidth + dx;
|
||||
|
||||
if (newWidth >= 200) {
|
||||
navPane.style.width = `${newWidth}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const mouseUpHandler = function () {
|
||||
document.removeEventListener('mousemove', mouseMoveHandler);
|
||||
document.removeEventListener('mouseup', mouseUpHandler);
|
||||
|
||||
fileNavResizer.classList.remove('bg-blue-500');
|
||||
};
|
||||
|
||||
fileNavResizer.addEventListener('mousedown', mouseDownHandler);
|
||||
}
|
||||
}
|
||||
|
||||
256
mdedit/js/toc.js
Normal file
256
mdedit/js/toc.js
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
/**
|
||||
* Table of Contents generation and scroll functionality
|
||||
*/
|
||||
|
||||
/**
|
||||
* Scroll to header service - uses line numbers for reliable targeting
|
||||
*/
|
||||
const ScrollToHeaderService = {
|
||||
/**
|
||||
* Scroll to a specific header in the editor by line number
|
||||
* @param {Object} editorInstance - Toast UI Editor instance
|
||||
* @param {string} headerText - Text content of the header (for highlighting)
|
||||
* @param {number} lineIndex - 0-based line index of the header in markdown
|
||||
*/
|
||||
scrollToHeader(editorInstance, headerText, lineIndex) {
|
||||
if (!editorInstance) {
|
||||
console.warn('Editor instance not available for scrolling');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const editorElements = editorInstance.getEditorElements();
|
||||
const isWysiwygMode = editorInstance.isWysiwygMode();
|
||||
|
||||
if (isWysiwygMode) {
|
||||
// In WYSIWYG mode, find header by text (no line numbers available)
|
||||
const wysiwygEditor = editorElements.wwEditor;
|
||||
if (wysiwygEditor) {
|
||||
const headers = wysiwygEditor.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
for (const header of headers) {
|
||||
if (header.textContent.trim() === headerText.trim()) {
|
||||
// Scroll the editor container directly with 10px offset
|
||||
const headerPosition = header.getBoundingClientRect().top - wysiwygEditor.getBoundingClientRect().top;
|
||||
const offset = 10; // Account for fixed headers or padding
|
||||
wysiwygEditor.scrollTop = headerPosition - offset;
|
||||
this._highlightHeader(header);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// In markdown mode, use line number to position cursor, then scroll preview
|
||||
const lineNumber = lineIndex + 1; // Convert to 1-based
|
||||
|
||||
// Move cursor to the heading line in the editor
|
||||
try {
|
||||
editorInstance.setSelection([lineNumber, 1], [lineNumber, 1]);
|
||||
} catch (e) {
|
||||
if (DEBUG) console.debug('Could not set selection:', e);
|
||||
}
|
||||
|
||||
// Scroll preview to matching header
|
||||
const previewElement = editorElements.mdPreview;
|
||||
if (previewElement) {
|
||||
const headers = previewElement.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
|
||||
for (const header of headers) {
|
||||
if (header.textContent.trim() === headerText.trim()) {
|
||||
// Scroll the preview container directly with 10px offset
|
||||
const headerPosition = header.getBoundingClientRect().top - previewElement.getBoundingClientRect().top;
|
||||
const offset = 10; // Account for fixed headers or padding
|
||||
previewElement.scrollTop = headerPosition - offset;
|
||||
this._highlightHeader(header);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error scrolling to header:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Highlight header briefly for visual feedback
|
||||
* @param {HTMLElement} headerElement - Header to highlight
|
||||
*/
|
||||
_highlightHeader(headerElement) {
|
||||
if (!headerElement) return;
|
||||
|
||||
headerElement.style.transition = 'background-color 0.3s ease';
|
||||
headerElement.style.backgroundColor = '#fef3c7';
|
||||
|
||||
setTimeout(() => {
|
||||
headerElement.style.backgroundColor = '';
|
||||
setTimeout(() => {
|
||||
headerElement.style.transition = '';
|
||||
}, 300);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate and update the TOC from markdown content
|
||||
* @param {string} content - Markdown content
|
||||
* @param {HTMLElement} tocContainer - Container for the TOC
|
||||
* @param {Object} editorInstance - Toast UI Editor instance
|
||||
* @param {number} maxDepth - Maximum heading level (1-6)
|
||||
*/
|
||||
function updateToc(content, tocContainer, editorInstance, maxDepth = 6) {
|
||||
if (content === undefined || content === null || !tocContainer) {
|
||||
console.warn('Missing required params for updateToc');
|
||||
return;
|
||||
}
|
||||
|
||||
tocContainer.innerHTML = '';
|
||||
|
||||
const tocList = document.createElement('ul');
|
||||
tocList.className = 'toc-list pl-0 text-sm';
|
||||
|
||||
if (!content.trim()) {
|
||||
const emptyMessage = document.createElement('p');
|
||||
emptyMessage.className = 'text-gray-500 p-4';
|
||||
emptyMessage.textContent = 'This file is empty.';
|
||||
tocContainer.appendChild(emptyMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const headings = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (match) {
|
||||
const level = match[1].length;
|
||||
let text = match[2].trim();
|
||||
|
||||
// Clean markdown formatting
|
||||
text = text
|
||||
.replace(/\\(.)/g, '$1')
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.*?)\*/g, '$1')
|
||||
.replace(/`(.*?)`/g, '$1')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.trim();
|
||||
|
||||
const id = text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
|
||||
|
||||
headings.push({
|
||||
level,
|
||||
text,
|
||||
id,
|
||||
lineIndex: index
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let currentList = tocList;
|
||||
let currentLevel = 0;
|
||||
let listsStack = [tocList];
|
||||
|
||||
const filteredHeadings = headings.filter(heading => heading.level <= maxDepth);
|
||||
|
||||
if (filteredHeadings.length === 0) {
|
||||
const noHeadings = document.createElement('p');
|
||||
noHeadings.className = 'text-gray-500 p-4';
|
||||
noHeadings.textContent = maxDepth === 6 ? 'No headings found in this document.' :
|
||||
'No headings at or below level H' + maxDepth + ' found.';
|
||||
tocContainer.appendChild(noHeadings);
|
||||
return;
|
||||
}
|
||||
|
||||
filteredHeadings.forEach(heading => {
|
||||
const li = document.createElement('li');
|
||||
li.className = `toc-item toc-level-${heading.level} py-1`;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.innerHTML = heading.text;
|
||||
a.href = '#';
|
||||
a.className = 'text-blue-600 hover:text-blue-800 hover:underline cursor-pointer';
|
||||
a.dataset.headerText = heading.text;
|
||||
a.dataset.lineIndex = heading.lineIndex;
|
||||
|
||||
a.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (editorInstance && ScrollToHeaderService) {
|
||||
try {
|
||||
ScrollToHeaderService.scrollToHeader(
|
||||
editorInstance,
|
||||
heading.text,
|
||||
parseInt(heading.lineIndex)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in ScrollToHeaderService.scrollToHeader:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
li.appendChild(a);
|
||||
|
||||
if (heading.level > currentLevel) {
|
||||
const nestedUl = document.createElement('ul');
|
||||
nestedUl.className = 'pl-4 mt-1';
|
||||
listsStack[listsStack.length - 1].appendChild(nestedUl);
|
||||
listsStack.push(nestedUl);
|
||||
currentList = nestedUl;
|
||||
currentLevel = heading.level;
|
||||
} else if (heading.level < currentLevel) {
|
||||
while (heading.level < currentLevel && listsStack.length > 1) {
|
||||
listsStack.pop();
|
||||
currentLevel--;
|
||||
}
|
||||
currentList = listsStack[listsStack.length - 1];
|
||||
}
|
||||
|
||||
currentList.appendChild(li);
|
||||
});
|
||||
|
||||
tocContainer.appendChild(tocList);
|
||||
clearActiveTocItem(tocContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear active TOC item from all items within the container
|
||||
* @param {HTMLElement} tocContainer - Container element holding the TOC
|
||||
*/
|
||||
function clearActiveTocItem(tocContainer) {
|
||||
if (!tocContainer) return;
|
||||
|
||||
const activeItems = tocContainer.querySelectorAll('.toc-active');
|
||||
activeItems.forEach(item => {
|
||||
item.classList.remove('toc-active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active TOC item by finding the link matching the header text
|
||||
* @param {HTMLElement} tocContainer - Container element holding the TOC
|
||||
* @param {string} headerText - Text of the header to match and activate
|
||||
*/
|
||||
function setActiveTocItem(tocContainer, headerText) {
|
||||
if (!tocContainer || !headerText) return;
|
||||
|
||||
// First clear any existing active items
|
||||
clearActiveTocItem(tocContainer);
|
||||
|
||||
// Find the link matching the header text
|
||||
const links = tocContainer.querySelectorAll('a[data-header-text]');
|
||||
for (const link of links) {
|
||||
if (link.dataset.headerText === headerText) {
|
||||
// Add toc-active class to the parent li element
|
||||
const li = link.parentElement;
|
||||
if (li) {
|
||||
li.classList.add('toc-active');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export globally
|
||||
window.updateToc = updateToc;
|
||||
window.clearActiveTocItem = clearActiveTocItem;
|
||||
window.setActiveTocItem = setActiveTocItem;
|
||||
104
mdedit/js/utils.js
Normal file
104
mdedit/js/utils.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Debounce function calls
|
||||
* @param {Function} func - Function to debounce
|
||||
* @param {number} wait - Wait time in milliseconds
|
||||
* @returns {Function} Debounced function
|
||||
*/
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function () {
|
||||
const context = this;
|
||||
const args = arguments;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file type icon based on file extension
|
||||
* @param {string} fileName - Name of the file
|
||||
* @returns {string} Emoji icon for the file type
|
||||
*/
|
||||
function getFileTypeIcon(fileName) {
|
||||
const extension = zddc.splitExtension(fileName).extension;
|
||||
|
||||
const iconMap = {
|
||||
// Documents
|
||||
'md': '📝',
|
||||
'markdown': '📝',
|
||||
'txt': '📄',
|
||||
'rtf': '📄',
|
||||
'doc': '📘',
|
||||
'docx': '📘',
|
||||
'odt': '📘',
|
||||
|
||||
// Web files
|
||||
'html': '🌐',
|
||||
'htm': '🌐',
|
||||
'css': '🎨',
|
||||
'js': '⚡',
|
||||
'json': '📋',
|
||||
'xml': '📊',
|
||||
'yaml': '⚙️',
|
||||
'yml': '⚙️',
|
||||
|
||||
// PDFs and presentations
|
||||
'pdf': '📕',
|
||||
'ppt': '📊',
|
||||
'pptx': '📊',
|
||||
'odp': '📊',
|
||||
|
||||
// Spreadsheets
|
||||
'xls': '📗',
|
||||
'xlsx': '📗',
|
||||
'csv': '📊',
|
||||
'ods': '📗',
|
||||
|
||||
// Images
|
||||
'png': '🖼️',
|
||||
'jpg': '🖼️',
|
||||
'jpeg': '🖼️',
|
||||
'gif': '🖼️',
|
||||
'svg': '🖼️',
|
||||
'webp': '🖼️',
|
||||
'bmp': '🖼️',
|
||||
|
||||
// Archives
|
||||
'zip': '📦',
|
||||
'rar': '📦',
|
||||
'tar': '📦',
|
||||
'gz': '📦',
|
||||
'7z': '📦',
|
||||
|
||||
// Code files
|
||||
'py': '🐍',
|
||||
'java': '☕',
|
||||
'cpp': '⚙️',
|
||||
'c': '⚙️',
|
||||
'h': '⚙️',
|
||||
'php': '🔧',
|
||||
'rb': '💎',
|
||||
'go': '🔵',
|
||||
'rs': '🦀',
|
||||
'swift': '🧡',
|
||||
'kt': '💜',
|
||||
|
||||
// Configuration
|
||||
'ini': '⚙️',
|
||||
'conf': '⚙️',
|
||||
'cfg': '⚙️',
|
||||
'env': '⚙️',
|
||||
|
||||
// Other
|
||||
'log': '📃',
|
||||
'sql': '🗄️',
|
||||
'db': '🗄️',
|
||||
'sqlite': '🗄️',
|
||||
};
|
||||
|
||||
return iconMap[extension] || '📄';
|
||||
}
|
||||
156
mdedit/template.html
Normal file
156
mdedit/template.html
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Markdown</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Toast UI Editor v3.2.2 -->
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/3.2.2/toastui-editor.min.css">
|
||||
<script src="https://uicdn.toast.com/editor/3.2.2/toastui-editor-all.min.js"></script>
|
||||
<style>
|
||||
{{CSS_PLACEHOLDER}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="flex flex-col h-screen w-full overflow-hidden">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Markdown</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</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 class="flex-1 overflow-hidden relative">
|
||||
<div class="resizable-pane horizontal flex flex-row relative w-full h-full overflow-hidden" id="root-pane" data-pane-type="root">
|
||||
<div class="pane nav-pane relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="file-nav" data-pane-type="file-nav" style="width: 450px; min-width: 200px;">
|
||||
<div class="pane-header flex flex-col px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700 select-none">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span>Files</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button id="new-file-root" class="btn btn-secondary btn-sm hidden" title="New file in root directory">+</button>
|
||||
<button id="refresh-directory" class="btn btn-secondary btn-sm hidden" title="Refresh directory">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-content flex-1 overflow-auto p-4">
|
||||
<div id="file-tree" class="file-tree py-2">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500" data-resizer-for="file-nav"></div>
|
||||
|
||||
<div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content">
|
||||
<div id="welcome-screen" class="welcome-screen flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
|
||||
<h2 class="mb-2 text-xl">Welcome to ZDDC Markdown</h2>
|
||||
<p class="mb-4">All files are edited on your local computer.</p>
|
||||
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> to start editing,<br>or <strong>Select Directory</strong> to work with files.</p>
|
||||
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and save via download.</p>
|
||||
</div>
|
||||
|
||||
<div id="content-container" class="content-container flex flex-col h-full hidden">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="status-bar flex justify-between items-center px-4 h-6 text-xs bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="status-left flex items-center gap-6 py-1">
|
||||
<button id="save-all" class="btn inline-flex items-center gap-2 px-3 py-1 text-sm bg-transparent border border-gray-300 dark:border-gray-600 rounded text-gray-800 dark:text-gray-200 cursor-pointer transition-all hover:bg-gray-200 dark:hover:bg-gray-700 h-6 leading-none" title="Save All">
|
||||
<svg class="btn-icon w-3.5 h-3.5 fill-current opacity-80" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11.5 1H4.5a1.5 1.5 0 0 0-1.5 1.5v11a1.5 1.5 0 0 0 1.5 1.5h7a1.5 1.5 0 0 0 1.5-1.5v-11a1.5 1.5 0 0 0-1.5-1.5zm-7 1h7a.5.5 0 0 1 .5.5V9H4V2.5a.5.5 0 0 1 .5-.5zM4 10h8v3.5a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5V10z"></path>
|
||||
<path d="M6.5 0a.5.5 0 0 1 .5.5V2h2V.5a.5.5 0 0 1 1 0V2h1.5a.5.5 0 0 1 0 1H10v2.5a.5.5 0 0 1-1 0V3H7v2.5a.5.5 0 0 1-1 0V3H4.5a.5.5 0 0 1 0-1H6V.5a.5.5 0 0 1 .5-.5z"></path>
|
||||
</svg>
|
||||
Save All
|
||||
</button>
|
||||
<span id="folder-count" class="status-message text-sm text-gray-800 dark:text-gray-200 opacity-80">0 folders</span>
|
||||
<span id="file-count" class="status-message text-sm text-gray-800 dark:text-gray-200 opacity-80">0 files</span>
|
||||
<span id="unsaved-count" class="status-message text-sm text-gray-800 dark:text-gray-200 opacity-80">0 unsaved</span>
|
||||
</div>
|
||||
<div class="status-right flex items-center gap-4">
|
||||
<a href="https://codeberg.org/VARASYS/ZDDC" target="_blank" rel="noopener noreferrer" class="source-link" title="View source code">
|
||||
<svg class="source-icon fill-current transition-opacity hover:opacity-80" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||
<path d="M5.5 11.5L1 8l4.5-3.5L4.4 3 0 8l4.4 5 1.1-1.5zm5 0L15 8l-4.5-3.5L11.6 3 16 8l-4.4 5-1.1-1.5z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 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 Markdown</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 ZDDC Markdown?</h3>
|
||||
<p>ZDDC Markdown is a browser-based Markdown editor that reads and writes files directly on your local file system. Everything runs locally — no data is sent to any server.</p>
|
||||
|
||||
<h3>Getting Started</h3>
|
||||
<ol>
|
||||
<li>Click <strong>Select Directory</strong> to open a folder. The file tree on the left will populate with all files in that folder.</li>
|
||||
<li>Click any Markdown file (<code>.md</code>) in the tree to open it in the editor.</li>
|
||||
<li>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Editor Modes</h3>
|
||||
<dl>
|
||||
<dt>WYSIWYG</dt>
|
||||
<dd>A rich-text view where formatting is rendered live. Good for composing content.</dd>
|
||||
<dt>Markdown</dt>
|
||||
<dd>A plain-text view showing raw Markdown syntax. Good for precise control.</dd>
|
||||
</dl>
|
||||
<p>Switch between modes using the toolbar buttons at the top-right of the editor.</p>
|
||||
|
||||
<h3>Saving Files</h3>
|
||||
<dl>
|
||||
<dt>Auto-save indicator</dt>
|
||||
<dd>A bullet (•) next to the filename in the tree indicates unsaved changes.</dd>
|
||||
<dt>Save (Ctrl+S)</dt>
|
||||
<dd>Saves the currently active file.</dd>
|
||||
<dt>Save All</dt>
|
||||
<dd>Saves all files that have unsaved changes in one operation.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Table of Contents</h3>
|
||||
<p>When a Markdown file is open, a table of contents is generated from its headings and shown on the right side. Use the depth selector to control how many heading levels appear.</p>
|
||||
|
||||
<h3>Browser Compatibility</h3>
|
||||
<p>File system access requires a Chromium-based browser (Chrome, Edge, Brave). In Firefox and other browsers, the <strong>Scratchpad</strong> is available for editing, and files can be saved via download.</p>
|
||||
|
||||
<h3>Keyboard Shortcuts</h3>
|
||||
<dl>
|
||||
<dt><kbd>Ctrl+S</kbd></dt>
|
||||
<dd>Save the current file.</dd>
|
||||
<dt><kbd>Escape</kbd></dt>
|
||||
<dd>Close this help panel.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- New-file modal -->
|
||||
<div id="new-file-modal" class="modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="new-file-modal-title">
|
||||
<div class="modal-box">
|
||||
<h3 id="new-file-modal-title" class="modal-title">New file name</h3>
|
||||
<input id="new-file-input" type="text" class="modal-input" value="untitled.md" autocomplete="off" spellcheck="false">
|
||||
<div class="modal-actions">
|
||||
<button id="new-file-cancel" class="btn btn-secondary">Cancel</button>
|
||||
<button id="new-file-confirm" class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{{JS_PLACEHOLDER}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
24
mdedit/vendor/toastui-editor-all.min.js
vendored
Normal file
24
mdedit/vendor/toastui-editor-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
mdedit/vendor/toastui-editor.min.css
vendored
Normal file
6
mdedit/vendor/toastui-editor.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
28
package.json
Normal file
28
package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "zddc",
|
||||
"version": "0.1.0",
|
||||
"description": "Zero Day Document Control - lightweight project management tools",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "npx playwright test",
|
||||
"test:headed": "npx playwright test --headed",
|
||||
"test:debug": "npx playwright test --debug",
|
||||
"test:install": "npx playwright install chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://codeberg.org/VARASYS/ZDDC.git"
|
||||
},
|
||||
"keywords": [
|
||||
"project-management",
|
||||
"document-control",
|
||||
"markdown-editor",
|
||||
"document-transmittal"
|
||||
],
|
||||
"license": "AGPL-3.0"
|
||||
}
|
||||
162
pandoc/README.md
Normal file
162
pandoc/README.md
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
# ZDDC Pandoc Tools
|
||||
|
||||
A collection of tools for converting Markdown documents to HTML with a professional viewer interface, optimized for technical documentation and engineering documents.
|
||||
|
||||
## Features
|
||||
|
||||
### Document Conversion (`convert`)
|
||||
- **Batch processing**: Convert multiple Markdown files at once
|
||||
- **Force overwrite**: `-f` flag to overwrite existing output files
|
||||
- **Custom output directory**: `-o` flag to specify output location
|
||||
- **Configuration-driven**: Uses `zddc.conf` for project-specific settings
|
||||
- **Template integration**: Automatically applies the viewer template
|
||||
- **Progress tracking**: Real-time conversion status and summary
|
||||
|
||||
### Professional Viewer Template (`viewer-template.html`)
|
||||
- **Modern responsive design**: Works on desktop, tablet, and mobile
|
||||
- **Table of Contents (TOC)**: Auto-generated sidebar navigation with smooth scrolling
|
||||
- **Print optimization**: Professional formatting for PDF generation
|
||||
- Page break controls for tables
|
||||
- Repeating table headers
|
||||
- Proper page numbering
|
||||
- Clean print layout
|
||||
- **URL hash navigation**: Shareable links to specific document sections
|
||||
- **Mobile-friendly**: Collapsible sidebar and touch-optimized interface
|
||||
- **Professional styling**: Clean typography optimized for technical documents
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Conversion
|
||||
```bash
|
||||
# Convert all Markdown files in current directory
|
||||
./convert *.md
|
||||
|
||||
# Convert with force overwrite
|
||||
./convert -f *.md
|
||||
|
||||
# Convert to specific output directory
|
||||
./convert -o rendered/ *.md
|
||||
|
||||
# Combine flags
|
||||
./convert -f -o rendered/ *.md
|
||||
```
|
||||
|
||||
### Configuration (`zddc.conf`)
|
||||
Create a `zddc.conf` file in your project directory:
|
||||
```ini
|
||||
# Project metadata
|
||||
title = "Project Documentation"
|
||||
author = "Your Organization"
|
||||
date = "2024"
|
||||
|
||||
# Template settings
|
||||
template = "/path/to/viewer-template.html"
|
||||
css = "custom-styles.css"
|
||||
|
||||
# Output settings
|
||||
output_dir = "rendered"
|
||||
```
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
your-project/
|
||||
├── zddc.conf # Configuration file
|
||||
├── document1.md # Source Markdown files
|
||||
├── document2.md
|
||||
└── rendered/ # Generated HTML files
|
||||
├── document1.html
|
||||
└── document2.html
|
||||
```
|
||||
|
||||
## Template Features
|
||||
|
||||
### Navigation
|
||||
- **TOC Generation**: Automatically creates navigation from document headings
|
||||
- **Smooth Scrolling**: Click TOC items for smooth navigation to sections
|
||||
- **Hash URLs**: Address bar updates with section anchors for sharing
|
||||
- **Mobile Menu**: Collapsible sidebar for mobile devices
|
||||
|
||||
### Print Styling
|
||||
- **Page Breaks**: Tables won't split across pages
|
||||
- **Header Repetition**: Table headers repeat on each page
|
||||
- **Professional Layout**: Optimized margins and typography
|
||||
- **Page Numbers**: Sequential page numbering in footer
|
||||
|
||||
### Responsive Design
|
||||
- **Desktop**: Full sidebar with TOC always visible
|
||||
- **Tablet**: Collapsible sidebar with overlay
|
||||
- **Mobile**: Hamburger menu with full-screen TOC overlay
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Templates
|
||||
You can customize the viewer template by:
|
||||
1. Copying `viewer-template.html` to your project
|
||||
2. Modifying the CSS and HTML structure
|
||||
3. Updating `zddc.conf` to point to your custom template
|
||||
|
||||
### Batch Processing
|
||||
For large document sets:
|
||||
```bash
|
||||
# Process all markdown files recursively
|
||||
find . -name "*.md" -exec ./convert -f -o rendered/ {} +
|
||||
|
||||
# Process specific document types
|
||||
./convert -f -o rendered/ *-SOW-*.md *-DBD-*.md
|
||||
```
|
||||
|
||||
### Integration with Build Systems
|
||||
The convert tool returns proper exit codes and can be integrated into CI/CD pipelines:
|
||||
```bash
|
||||
# In a build script
|
||||
if ./convert -f -o dist/ *.md; then
|
||||
echo "Documentation built successfully"
|
||||
else
|
||||
echo "Documentation build failed"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## File Types Supported
|
||||
|
||||
- **Input**: Markdown (`.md`) files with pandoc extensions
|
||||
- **Output**: HTML files with embedded CSS and JavaScript
|
||||
- **Images**: Supports embedded images and diagrams
|
||||
- **Tables**: Full table support with print optimization
|
||||
- **Code**: Syntax highlighting for code blocks
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **pandoc**: Document conversion engine
|
||||
- **Modern browser**: For viewing generated HTML files
|
||||
- **Optional**: Web server for serving files (prevents CORS issues)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
1. **Template not found**: Ensure `zddc.conf` points to correct template path
|
||||
2. **Permission errors**: Make sure `convert` script is executable (`chmod +x convert`)
|
||||
3. **Missing output**: Check that output directory exists or use `-o` to create it
|
||||
4. **Print issues**: Use "Print to PDF" in browser for best results
|
||||
|
||||
### Performance
|
||||
- Large documents (>1000 pages) may take longer to render
|
||||
- Consider splitting very large documents into sections
|
||||
- Use batch processing for multiple files
|
||||
|
||||
## Examples
|
||||
|
||||
### Engineering Documentation
|
||||
Perfect for:
|
||||
- Design basis documents
|
||||
- Specifications and standards
|
||||
- Project requirements
|
||||
- Technical procedures
|
||||
- Quality documentation
|
||||
|
||||
### Features Optimized For
|
||||
- **Professional appearance**: Clean, corporate styling
|
||||
- **Technical content**: Tables, diagrams, code blocks
|
||||
- **Print output**: PDF generation with proper formatting
|
||||
- **Navigation**: Easy browsing of long documents
|
||||
- **Sharing**: URL fragments for referencing specific sections
|
||||
552
pandoc/convert
Normal file
552
pandoc/convert
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Function to show help
|
||||
show_help() {
|
||||
echo "Universal File Converter"
|
||||
echo "Supported conversions: DOCX→MD, MD→HTML, HTML→MD, MD→DOCX, HTML→DOCX"
|
||||
echo "Usage: $0 [-f] [-o outputdir] [-t format] [-T template] [--no-toc] input1.ext [input2.ext ...]"
|
||||
echo " -f: Force overwrite existing output files"
|
||||
echo " -o: Output directory (default: same as input)"
|
||||
echo " -t: Target format (md, html, docx) - overrides auto-detection"
|
||||
echo " -T: Template file path (default: viewer-template.html)"
|
||||
echo " --no-toc: Skip table of contents generation"
|
||||
}
|
||||
|
||||
# Function to source ZDDC config files if they exist
|
||||
source_config_file() {
|
||||
local config_file="$1"
|
||||
if [ -f "$config_file" ]; then
|
||||
echo " → Loading ZDDC configuration from: $config_file"
|
||||
set -a # automatically export all variables
|
||||
. "$config_file"
|
||||
set +a # turn off automatic export
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Load ZDDC configuration file
|
||||
load_zddc_config() {
|
||||
local search_dir="$1"
|
||||
|
||||
# Search for zddc.conf then .zddc.conf in the search directory
|
||||
if source_config_file "$search_dir/zddc.conf"; then
|
||||
return 0
|
||||
elif source_config_file "$search_dir/.zddc.conf"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# No config file found - continue with defaults
|
||||
return 1
|
||||
}
|
||||
|
||||
# Source global ZDDC config from current working directory
|
||||
# This is called once at startup - do NOT call again inside convert functions
|
||||
load_zddc_config "$(pwd)"
|
||||
|
||||
# Parse arguments
|
||||
FORCE_OVERWRITE=false
|
||||
OUTPUT_DIR=""
|
||||
TARGET_FORMAT=""
|
||||
CUSTOM_TEMPLATE=""
|
||||
NO_TOC=false
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
-f)
|
||||
FORCE_OVERWRITE=true
|
||||
echo "Force overwrite mode: ON"
|
||||
shift
|
||||
;;
|
||||
-o)
|
||||
OUTPUT_DIR="$2"
|
||||
echo "Output directory: $OUTPUT_DIR"
|
||||
shift 2
|
||||
;;
|
||||
-t)
|
||||
TARGET_FORMAT="$2"
|
||||
echo "Target format: $TARGET_FORMAT"
|
||||
shift 2
|
||||
;;
|
||||
-T)
|
||||
CUSTOM_TEMPLATE="$2"
|
||||
echo "Custom template: $CUSTOM_TEMPLATE"
|
||||
shift 2
|
||||
;;
|
||||
--no-toc)
|
||||
NO_TOC=true
|
||||
echo "Table of contents: DISABLED"
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
echo "Unknown option: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$FORCE_OVERWRITE" = "false" ]; then
|
||||
echo "Force overwrite mode: OFF (will skip existing output files)"
|
||||
fi
|
||||
|
||||
if [ -z "$OUTPUT_DIR" ]; then
|
||||
echo "Output directory: same as input files"
|
||||
fi
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Error: No input files specified"
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate target format if specified
|
||||
if [ -n "$TARGET_FORMAT" ]; then
|
||||
TARGET_FORMAT_LOWER=$(echo "$TARGET_FORMAT" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$TARGET_FORMAT_LOWER" != "md" ] && [ "$TARGET_FORMAT_LOWER" != "html" ] && [ "$TARGET_FORMAT_LOWER" != "docx" ]; then
|
||||
echo "Error: Invalid target format '$TARGET_FORMAT'. Supported: md, html, docx"
|
||||
exit 1
|
||||
fi
|
||||
echo "Target format override: $TARGET_FORMAT_LOWER"
|
||||
fi
|
||||
|
||||
echo "Processing $# files..."
|
||||
|
||||
TOTAL_FILES=$#
|
||||
SUCCESSFUL=0
|
||||
FAILED=0
|
||||
SKIPPED=0
|
||||
|
||||
# Function to convert DOCX to Markdown
|
||||
convert_docx_to_md() {
|
||||
local INPUT="$1"
|
||||
local OUTPUT_FILE="$2"
|
||||
local TEMP_FILE="$3"
|
||||
local MEDIA_DIR="$4"
|
||||
local BASENAME="$5"
|
||||
local FILENAME_NO_EXT="$6"
|
||||
|
||||
# Convert using pandoc with proper extension stripping to temp file first
|
||||
if pandoc -f docx -t gfm --markdown-headings=atx --extract-media="$MEDIA_DIR" --wrap=none --standalone "$INPUT" -o "$TEMP_FILE"; then
|
||||
|
||||
# Parse ZDDC filename pattern: trackingNumber_revision (status) - title.extension
|
||||
# Use sed to extract ZDDC components
|
||||
ZDDC_MATCH=$(echo "$FILENAME_NO_EXT" | sed -n 's/^\([^_]*\)_\([^ ]*\) *(\([^)]*\)) *- *\(.*\)$/\1|\2|\3|\4/p')
|
||||
if [ -n "$ZDDC_MATCH" ]; then
|
||||
TRACKING_NUMBER=$(echo "$ZDDC_MATCH" | cut -d'|' -f1)
|
||||
REVISION=$(echo "$ZDDC_MATCH" | cut -d'|' -f2)
|
||||
STATUS=$(echo "$ZDDC_MATCH" | cut -d'|' -f3)
|
||||
TITLE=$(echo "$ZDDC_MATCH" | cut -d'|' -f4)
|
||||
|
||||
echo " → ZDDC metadata detected:"
|
||||
echo " • Tracking: $TRACKING_NUMBER"
|
||||
echo " • Revision: $REVISION"
|
||||
echo " • Status: $STATUS"
|
||||
echo " • Title: $TITLE"
|
||||
|
||||
# Create YAML front matter and combine with content
|
||||
{
|
||||
echo "---"
|
||||
echo "client: \"${CLIENT:-}\""
|
||||
echo "project: \"${PROJECT:-}\""
|
||||
echo "tracking_number: \"$TRACKING_NUMBER\""
|
||||
echo "revision: \"$REVISION\""
|
||||
echo "status: \"$STATUS\""
|
||||
echo "title: \"$TITLE\""
|
||||
echo "source_file: \"$BASENAME\""
|
||||
echo "created: \"$(date -u +%Y-%m-%d)\""
|
||||
echo "---"
|
||||
echo ""
|
||||
cat "$TEMP_FILE"
|
||||
} > "$OUTPUT_FILE"
|
||||
|
||||
rm "$TEMP_FILE"
|
||||
else
|
||||
# No ZDDC pattern detected, just move temp file to final location
|
||||
mv "$TEMP_FILE" "$OUTPUT_FILE"
|
||||
fi
|
||||
|
||||
echo " ✓ Successfully converted: $BASENAME (DOCX→MD)"
|
||||
return 0
|
||||
else
|
||||
echo " ✗ Failed to convert: $BASENAME (DOCX→MD)"
|
||||
# Clean up temp file on failure
|
||||
[ -f "$TEMP_FILE" ] && rm "$TEMP_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to convert HTML to Markdown
|
||||
convert_html_to_md() {
|
||||
local INPUT="$1"
|
||||
local OUTPUT_FILE="$2"
|
||||
local BASENAME="$3"
|
||||
|
||||
if pandoc "$INPUT" -f html -t gfm --markdown-headings=atx --wrap=none -o "$OUTPUT_FILE"; then
|
||||
echo " ✓ Successfully converted: $BASENAME (HTML→MD)"
|
||||
return 0
|
||||
else
|
||||
echo " ✗ Failed to convert: $BASENAME (HTML→MD)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to convert Markdown to DOCX
|
||||
convert_md_to_docx() {
|
||||
local INPUT="$1"
|
||||
local OUTPUT_FILE="$2"
|
||||
local BASENAME="$3"
|
||||
|
||||
if pandoc "$INPUT" -f gfm -t docx -o "$OUTPUT_FILE"; then
|
||||
echo " ✓ Successfully converted: $BASENAME (MD→DOCX)"
|
||||
return 0
|
||||
else
|
||||
echo " ✗ Failed to convert: $BASENAME (MD→DOCX)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to convert HTML to DOCX
|
||||
convert_html_to_docx() {
|
||||
local INPUT="$1"
|
||||
local OUTPUT_FILE="$2"
|
||||
local BASENAME="$3"
|
||||
|
||||
if pandoc "$INPUT" -f html -t docx -o "$OUTPUT_FILE"; then
|
||||
echo " ✓ Successfully converted: $BASENAME (HTML→DOCX)"
|
||||
return 0
|
||||
else
|
||||
echo " ✗ Failed to convert: $BASENAME (HTML→DOCX)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to convert Markdown to HTML
|
||||
convert_md_to_html() {
|
||||
local INPUT="$1"
|
||||
local OUTPUT_FILE="$2"
|
||||
local BASENAME="$3"
|
||||
local INPUT_DIR="$4"
|
||||
|
||||
# No need to reload config - already loaded at startup
|
||||
# Config variables from zddc.conf are already in environment
|
||||
|
||||
# Get absolute paths - use pwd-based approach for POSIX compatibility
|
||||
case "$INPUT" in
|
||||
/*) INPUT_ABS="$INPUT" ;;
|
||||
*) INPUT_ABS="$(pwd)/$INPUT" ;;
|
||||
esac
|
||||
case "$OUTPUT_FILE" in
|
||||
/*) OUTPUT_ABS="$OUTPUT_FILE" ;;
|
||||
*) OUTPUT_ABS="$(pwd)/$OUTPUT_FILE" ;;
|
||||
esac
|
||||
# Determine template to use
|
||||
if [ -n "$CUSTOM_TEMPLATE" ]; then
|
||||
# Use custom template if specified
|
||||
if [ -f "$CUSTOM_TEMPLATE" ]; then
|
||||
TEMPLATE_ABS="$CUSTOM_TEMPLATE"
|
||||
echo " → Using custom template: $TEMPLATE_ABS"
|
||||
else
|
||||
echo " ⚠ Warning: Custom template not found: $CUSTOM_TEMPLATE, using default discovery"
|
||||
CUSTOM_TEMPLATE=""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Default template discovery if no custom template or custom template not found
|
||||
if [ -z "$CUSTOM_TEMPLATE" ]; then
|
||||
# Convert script directory to absolute path
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
# Check if script is a symlink and resolve target directory
|
||||
SCRIPT_TARGET_DIR=""
|
||||
if [ -L "$0" ]; then
|
||||
# Script is a symlink - resolve the target fully
|
||||
# readlink -f is available on Linux with GNU coreutils
|
||||
SCRIPT_TARGET=$(readlink -f "$0")
|
||||
SCRIPT_TARGET_DIR=$(dirname "$SCRIPT_TARGET")
|
||||
fi
|
||||
|
||||
# Template search order: input dir, script dir, symlink target dir
|
||||
if [ -f "$INPUT_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$INPUT_DIR/viewer-template.html"
|
||||
echo " → Using template from input directory: $TEMPLATE_ABS"
|
||||
elif [ -f "$SCRIPT_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_DIR/viewer-template.html"
|
||||
echo " → Using template from script directory: $TEMPLATE_ABS"
|
||||
elif [ -n "$SCRIPT_TARGET_DIR" ] && [ -f "$SCRIPT_TARGET_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_TARGET_DIR/viewer-template.html"
|
||||
echo " → Using template from symlink target directory: $TEMPLATE_ABS"
|
||||
else
|
||||
echo " ⚠ Warning: viewer-template.html not found, using pandoc default template"
|
||||
TEMPLATE_ABS=""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Change to input directory so pandoc can find relative resources
|
||||
ORIGINAL_DIR=$(pwd)
|
||||
cd "$INPUT_DIR"
|
||||
|
||||
# Build pandoc command using positional arguments (安全方式,无 eval)
|
||||
# 以空格分隔的参数数组,避免 shell 注入
|
||||
PANDOC_ARGS=()
|
||||
PANDOC_ARGS+=("--from" "markdown+yaml_metadata_block")
|
||||
PANDOC_ARGS+=("--standalone")
|
||||
PANDOC_ARGS+=("--embed-resources")
|
||||
PANDOC_ARGS+=("--section-divs")
|
||||
|
||||
# Add TOC options if not disabled
|
||||
if [ "$NO_TOC" = "false" ]; then
|
||||
PANDOC_ARGS+=("--toc" "--toc-depth=6")
|
||||
fi
|
||||
|
||||
if [ -n "$TEMPLATE_ABS" ]; then
|
||||
PANDOC_ARGS+=("--template" "$TEMPLATE_ABS")
|
||||
fi
|
||||
|
||||
# Generate timestamp for conversion (force English locale)
|
||||
GENERATION_TIME=$(LC_TIME=C date '+%B %d, %Y at %I:%M:%S %p %Z')
|
||||
|
||||
# Extract ZDDC metadata from filename for template variables
|
||||
FILENAME_NO_EXT=$(basename "$INPUT" .md)
|
||||
ZDDC_MATCH=$(echo "$FILENAME_NO_EXT" | sed -n 's/^\([^_]*\)_\([^ ]*\) *(\([^)]*\)) *- *\(.*\)$/\1|\2|\3|\4/p')
|
||||
if [ -n "$ZDDC_MATCH" ]; then
|
||||
TRACKING_NUMBER=$(echo "$ZDDC_MATCH" | cut -d'|' -f1)
|
||||
REVISION=$(echo "$ZDDC_MATCH" | cut -d'|' -f2)
|
||||
STATUS=$(echo "$ZDDC_MATCH" | cut -d'|' -f3)
|
||||
TITLE=$(echo "$ZDDC_MATCH" | cut -d'|' -f4)
|
||||
|
||||
# Pass ZDDC variables to template (each as separate args to avoid injection)
|
||||
PANDOC_ARGS+=("--variable" "tracking_number=$TRACKING_NUMBER")
|
||||
PANDOC_ARGS+=("--variable" "revision=$REVISION")
|
||||
PANDOC_ARGS+=("--variable" "status=$STATUS")
|
||||
PANDOC_ARGS+=("--variable" "generation_time=$GENERATION_TIME")
|
||||
PANDOC_ARGS+=("--variable" "title=$TITLE")
|
||||
case "$REVISION" in
|
||||
*~*)
|
||||
PANDOC_ARGS+=("--variable" "is_draft=true")
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# Still pass generation time even if no ZDDC match
|
||||
PANDOC_ARGS+=("--variable" "generation_time=$GENERATION_TIME")
|
||||
fi
|
||||
|
||||
# Add ZDDC configuration variables from zddc.conf
|
||||
if [ -n "$client" ]; then
|
||||
PANDOC_ARGS+=("--variable" "client=$client")
|
||||
fi
|
||||
if [ -n "$project" ]; then
|
||||
PANDOC_ARGS+=("--variable" "project=$project")
|
||||
fi
|
||||
if [ -n "$contractor" ]; then
|
||||
PANDOC_ARGS+=("--variable" "contractor=$contractor")
|
||||
fi
|
||||
if [ -n "$project_number" ]; then
|
||||
PANDOC_ARGS+=("--variable" "project_number=$project_number")
|
||||
fi
|
||||
|
||||
# Pass TOC status to template
|
||||
if [ "$NO_TOC" = "true" ]; then
|
||||
PANDOC_ARGS+=("--variable" "no-toc=true")
|
||||
fi
|
||||
|
||||
PANDOC_ARGS+=("--section-divs")
|
||||
PANDOC_ARGS+=("--id-prefix=")
|
||||
PANDOC_ARGS+=("--html-q-tags")
|
||||
|
||||
# Run pandoc with positional arguments (安全方式)
|
||||
# All variables passed as separate arguments to avoid shell injection
|
||||
if pandoc "$(basename "$INPUT_ABS")" -o "$OUTPUT_ABS" "${PANDOC_ARGS[@]}"; then
|
||||
|
||||
echo " ✓ Successfully converted: $BASENAME (MD→HTML)"
|
||||
cd "$ORIGINAL_DIR"
|
||||
return 0
|
||||
else
|
||||
echo " ✗ Failed to convert: $BASENAME (MD→HTML)"
|
||||
cd "$ORIGINAL_DIR"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
for INPUT in "$@"; do
|
||||
echo ""
|
||||
echo "Processing: $INPUT"
|
||||
|
||||
# Validate input file exists
|
||||
if [ ! -f "$INPUT" ]; then
|
||||
echo " ✗ Input file not found: $INPUT"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract filename without path and extension
|
||||
BASENAME=$(basename "$INPUT")
|
||||
FILENAME_NO_EXT="${BASENAME%.*}"
|
||||
EXTENSION="${BASENAME##*.}"
|
||||
INPUT_DIR=$(dirname "$INPUT")
|
||||
|
||||
# Convert extension to lowercase for comparison
|
||||
EXTENSION_LOWER=$(echo "$EXTENSION" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# Determine conversion type based on target format override or auto-detection
|
||||
if [ -n "$TARGET_FORMAT" ]; then
|
||||
# Target format specified - determine conversion type
|
||||
TARGET_EXT="$TARGET_FORMAT_LOWER"
|
||||
case "$EXTENSION_LOWER" in
|
||||
docx)
|
||||
if [ "$TARGET_EXT" = "md" ]; then
|
||||
CONVERSION_TYPE="docx2md"
|
||||
elif [ "$TARGET_EXT" = "html" ]; then
|
||||
echo " ✗ Direct DOCX→HTML conversion not supported. Convert to MD first."
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
elif [ "$TARGET_EXT" = "docx" ]; then
|
||||
echo " ⚠ Skipping: $BASENAME (already DOCX format)"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
md)
|
||||
if [ "$TARGET_EXT" = "html" ]; then
|
||||
CONVERSION_TYPE="md2html"
|
||||
elif [ "$TARGET_EXT" = "docx" ]; then
|
||||
CONVERSION_TYPE="md2docx"
|
||||
elif [ "$TARGET_EXT" = "md" ]; then
|
||||
echo " ⚠ Skipping: $BASENAME (already MD format)"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
html|htm)
|
||||
if [ "$TARGET_EXT" = "md" ]; then
|
||||
CONVERSION_TYPE="html2md"
|
||||
elif [ "$TARGET_EXT" = "docx" ]; then
|
||||
CONVERSION_TYPE="html2docx"
|
||||
elif [ "$TARGET_EXT" = "html" ]; then
|
||||
echo " ⚠ Skipping: $BASENAME (already HTML format)"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo " ✗ Unsupported input file type: .$EXTENSION (supported: .docx, .md, .html, .htm)"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# Auto-detect conversion type based on input extension
|
||||
case "$EXTENSION_LOWER" in
|
||||
docx)
|
||||
CONVERSION_TYPE="docx2md"
|
||||
TARGET_EXT="md"
|
||||
;;
|
||||
md)
|
||||
CONVERSION_TYPE="md2html"
|
||||
TARGET_EXT="html"
|
||||
;;
|
||||
html|htm)
|
||||
CONVERSION_TYPE="html2md"
|
||||
TARGET_EXT="md"
|
||||
;;
|
||||
*)
|
||||
echo " ✗ Unsupported file type: .$EXTENSION (supported: .docx, .md, .html, .htm)"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Determine output location
|
||||
if [ -n "$OUTPUT_DIR" ]; then
|
||||
OUTPUT_FILE="$OUTPUT_DIR/$FILENAME_NO_EXT.$TARGET_EXT"
|
||||
if [ "$CONVERSION_TYPE" = "docx2md" ]; then
|
||||
TEMP_FILE="$OUTPUT_DIR/temp_$FILENAME_NO_EXT.md"
|
||||
MEDIA_DIR="$OUTPUT_DIR/$FILENAME_NO_EXT"
|
||||
fi
|
||||
else
|
||||
OUTPUT_FILE="$INPUT_DIR/$FILENAME_NO_EXT.$TARGET_EXT"
|
||||
if [ "$CONVERSION_TYPE" = "docx2md" ]; then
|
||||
TEMP_FILE="$INPUT_DIR/temp_$FILENAME_NO_EXT.md"
|
||||
MEDIA_DIR="$INPUT_DIR/$FILENAME_NO_EXT"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " → Output file: $OUTPUT_FILE"
|
||||
if [ "$CONVERSION_TYPE" = "docx2md" ]; then
|
||||
echo " → Media dir: $MEDIA_DIR/"
|
||||
fi
|
||||
|
||||
# Create output directory if needed
|
||||
OUTPUT_FILE_DIR=$(dirname "$OUTPUT_FILE")
|
||||
if [ ! -d "$OUTPUT_FILE_DIR" ]; then
|
||||
mkdir -p "$OUTPUT_FILE_DIR"
|
||||
fi
|
||||
|
||||
# Check if output file exists and handle accordingly
|
||||
if [ -f "$OUTPUT_FILE" ] && [ "$FORCE_OVERWRITE" = "false" ]; then
|
||||
echo " ⚠ Skipped (file exists): $BASENAME"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Perform conversion based on type
|
||||
case "$CONVERSION_TYPE" in
|
||||
docx2md)
|
||||
if convert_docx_to_md "$INPUT" "$OUTPUT_FILE" "$TEMP_FILE" "$MEDIA_DIR" "$BASENAME" "$FILENAME_NO_EXT"; then
|
||||
SUCCESSFUL=$((SUCCESSFUL + 1))
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
;;
|
||||
md2html)
|
||||
if convert_md_to_html "$INPUT" "$OUTPUT_FILE" "$BASENAME" "$INPUT_DIR"; then
|
||||
SUCCESSFUL=$((SUCCESSFUL + 1))
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
;;
|
||||
html2md)
|
||||
if convert_html_to_md "$INPUT" "$OUTPUT_FILE" "$BASENAME"; then
|
||||
SUCCESSFUL=$((SUCCESSFUL + 1))
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
;;
|
||||
md2docx)
|
||||
if convert_md_to_docx "$INPUT" "$OUTPUT_FILE" "$BASENAME"; then
|
||||
SUCCESSFUL=$((SUCCESSFUL + 1))
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
;;
|
||||
html2docx)
|
||||
if convert_html_to_docx "$INPUT" "$OUTPUT_FILE" "$BASENAME"; then
|
||||
SUCCESSFUL=$((SUCCESSFUL + 1))
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo " ✗ Unknown conversion type: $CONVERSION_TYPE"
|
||||
FAILED=$((FAILED + 1))
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "CONVERSION SUMMARY"
|
||||
echo "=========================================="
|
||||
echo "Total files processed: $TOTAL_FILES"
|
||||
echo "Successful conversions: $SUCCESSFUL"
|
||||
echo "Failed conversions: $FAILED"
|
||||
echo "Skipped (existing files): $SKIPPED"
|
||||
echo "=========================================="
|
||||
579
pandoc/convert-diff
Normal file
579
pandoc/convert-diff
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
#!/bin/bash
|
||||
|
||||
# convert-diff - Batch diff converter for markdown files
|
||||
# Compares pairs of files and outputs HTML diffs using the same template as convert script
|
||||
|
||||
# ===== Configuration and variables =====
|
||||
CUSTOM_TEMPLATE=""
|
||||
NO_TOC=false
|
||||
|
||||
# Function to show help
|
||||
show_help() {
|
||||
echo "Batch Markdown Diff Converter"
|
||||
echo "Compares pairs of markdown files and outputs HTML diffs using the same template as convert script"
|
||||
echo "Usage: $0 [-f] [-o outputdir] [-T template] [--no-toc] file1_rev_a.md file1_rev_b.md [file2_rev_a.md file1_rev_b.md ...]"
|
||||
echo " -f: Force overwrite existing output files"
|
||||
echo " -o: Output directory (default: same as first input file)"
|
||||
echo " -T: Template file path (default: viewer-template.html)"
|
||||
echo " --no-toc: Skip table of contents generation"
|
||||
echo ""
|
||||
echo "Arguments:"
|
||||
echo " Files must be provided in pairs (revision A, revision B)"
|
||||
echo " Total number of files must be even"
|
||||
echo " Output files will be named: basename_diff.html"
|
||||
}
|
||||
|
||||
# Function to source ZDDC config files if they exist
|
||||
source_config_file() {
|
||||
local config_file="$1"
|
||||
if [ -f "$config_file" ]; then
|
||||
echo " → Loading ZDDC configuration from: $config_file"
|
||||
set -a # automatically export all variables
|
||||
. "$config_file"
|
||||
set +a # turn off automatic export
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Load ZDDC configuration file
|
||||
load_zddc_config() {
|
||||
local search_dir="$1"
|
||||
|
||||
# Search for zddc.conf then .zddc.conf in the search directory
|
||||
if source_config_file "$search_dir/zddc.conf"; then
|
||||
return 0
|
||||
elif source_config_file "$search_dir/.zddc.conf"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# No config file found - continue with defaults
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to inject diff controls and styling into HTML
|
||||
inject_diff_controls() {
|
||||
local html_file="$1"
|
||||
|
||||
if [ ! -f "$html_file" ]; then
|
||||
echo "Error: HTML file not found: $html_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Create temporary files for each component
|
||||
local temp_dir=$(mktemp -d)
|
||||
local css_file="$temp_dir/diff.css"
|
||||
local controls_file="$temp_dir/controls.html"
|
||||
local js_file="$temp_dir/diff.js"
|
||||
|
||||
# Write CSS to file
|
||||
cat > "$css_file" << 'EOF'
|
||||
/* Diff element styling */
|
||||
del {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
text-decoration: line-through;
|
||||
padding: 0.1em 0.2em;
|
||||
border-radius: 0.2em;
|
||||
}
|
||||
|
||||
u {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
text-decoration: none;
|
||||
padding: 0.1em 0.2em;
|
||||
border-radius: 0.2em;
|
||||
}
|
||||
|
||||
/* Dark mode diff styling */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
u {
|
||||
background-color: #1e4620;
|
||||
color: #75dd79;
|
||||
}
|
||||
|
||||
del {
|
||||
background-color: #4a1e1e;
|
||||
color: #f97583;
|
||||
}
|
||||
}
|
||||
|
||||
/* View mode controls */
|
||||
.diff-controls {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: var(--bg-secondary, #ffffff);
|
||||
border: 1px solid var(--border-color, #e1e5e9);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.diff-controls h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.view-mode-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.view-mode-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color, #e1e5e9);
|
||||
background: var(--bg-primary, #ffffff);
|
||||
color: var(--text-color, #333);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-mode-btn:hover {
|
||||
background: var(--hover-bg, #f8f9fa);
|
||||
}
|
||||
|
||||
.view-mode-btn.active {
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
border-color: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
/* View mode states */
|
||||
body.view-original u {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.view-original del {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body.view-final del {
|
||||
display: none;
|
||||
}
|
||||
EOF
|
||||
|
||||
# Write controls HTML to file
|
||||
cat > "$controls_file" << 'EOF'
|
||||
<!-- Diff View Controls -->
|
||||
<div class="diff-controls">
|
||||
<h4>View Mode</h4>
|
||||
<div class="view-mode-buttons">
|
||||
<button class="view-mode-btn active" data-mode="diff">Diff</button>
|
||||
<button class="view-mode-btn" data-mode="original">Original</button>
|
||||
<button class="view-mode-btn" data-mode="final">Final</button>
|
||||
</div>
|
||||
</div>
|
||||
EOF
|
||||
|
||||
# Write JavaScript to file
|
||||
cat > "$js_file" << 'EOF'
|
||||
<script>
|
||||
// View mode toggle functionality
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
var buttons = document.querySelectorAll(".view-mode-btn");
|
||||
var body = document.body;
|
||||
|
||||
buttons.forEach(function(button) {
|
||||
button.addEventListener("click", function() {
|
||||
// Remove active class from all buttons
|
||||
buttons.forEach(function(btn) { btn.classList.remove("active"); });
|
||||
|
||||
// Add active class to clicked button
|
||||
this.classList.add("active");
|
||||
|
||||
// Update body class for view mode
|
||||
body.className = body.className.replace(/view-\w+/g, "");
|
||||
if (this.dataset.mode !== "diff") {
|
||||
body.classList.add("view-" + this.dataset.mode);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
EOF
|
||||
|
||||
# Create working copy
|
||||
local temp_html="$temp_dir/temp.html"
|
||||
cp "$html_file" "$temp_html"
|
||||
|
||||
# Insert CSS before </head>
|
||||
sed -i '/<\/head>/r '"$css_file" "$temp_html"
|
||||
|
||||
# Insert controls after opening <body> tag
|
||||
sed -i '/<body[^>]*>/r '"$controls_file" "$temp_html"
|
||||
|
||||
# Insert JavaScript before </body>
|
||||
sed -i '/<\/body>/r '"$js_file" "$temp_html"
|
||||
|
||||
# Replace original file
|
||||
mv "$temp_html" "$html_file"
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$temp_dir"
|
||||
|
||||
echo " ✓ Diff controls injected successfully"
|
||||
}
|
||||
|
||||
# Source global ZDDC config from current working directory
|
||||
load_zddc_config "$(pwd)"
|
||||
|
||||
# Resolve script directory for template discovery
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
# Resolve symlink if needed
|
||||
if [ -L "$0" ]; then
|
||||
SCRIPT_TARGET=$(readlink -f "$0")
|
||||
SCRIPT_TARGET_DIR=$(dirname "$SCRIPT_TARGET")
|
||||
else
|
||||
SCRIPT_TARGET_DIR="$SCRIPT_DIR"
|
||||
fi
|
||||
|
||||
# Parse arguments
|
||||
FORCE_OVERWRITE=false
|
||||
OUTPUT_DIR=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
-f)
|
||||
FORCE_OVERWRITE=true
|
||||
echo "Force overwrite mode: ON"
|
||||
shift
|
||||
;;
|
||||
-o)
|
||||
OUTPUT_DIR="$2"
|
||||
echo "Output directory: $OUTPUT_DIR"
|
||||
shift 2
|
||||
;;
|
||||
-T)
|
||||
CUSTOM_TEMPLATE="$2"
|
||||
echo "Custom template: $CUSTOM_TEMPLATE"
|
||||
shift 2
|
||||
;;
|
||||
--no-toc)
|
||||
NO_TOC=true
|
||||
echo "Table of contents: DISABLED"
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
echo "Unknown option: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$FORCE_OVERWRITE" = "false" ]; then
|
||||
echo "Force overwrite mode: OFF (will skip existing output files)"
|
||||
fi
|
||||
|
||||
if [ -z "$OUTPUT_DIR" ]; then
|
||||
echo "Output directory: same as input files"
|
||||
fi
|
||||
|
||||
# Validate file count
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Error: No input files specified"
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $(($# % 2)) -ne 0 ]; then
|
||||
echo "Error: Number of files must be even (pairs of files for comparison)"
|
||||
echo "Provided $# files, but need pairs"
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOTAL_PAIRS=$(($# / 2))
|
||||
echo "Processing $TOTAL_PAIRS file pairs..."
|
||||
|
||||
SUCCESSFUL=0
|
||||
FAILED=0
|
||||
SKIPPED=0
|
||||
|
||||
# Process files in pairs
|
||||
while [ $# -gt 0 ]; do
|
||||
FILE1="$1"
|
||||
FILE2="$2"
|
||||
shift 2
|
||||
|
||||
echo ""
|
||||
echo "Processing pair: $(basename "$FILE1") vs $(basename "$FILE2")"
|
||||
|
||||
# Validate input files exist
|
||||
if [ ! -f "$FILE1" ]; then
|
||||
echo " ✗ First file not found: $FILE1"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ ! -f "$FILE2" ]; then
|
||||
echo " ✗ Second file not found: $FILE2"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract base filename for output
|
||||
BASENAME1=$(basename "$FILE1" .md)
|
||||
BASENAME2=$(basename "$FILE2" .md)
|
||||
|
||||
# Generate output filename with _diff suffix (ZDDC compliant)
|
||||
OUTPUT_BASENAME="${BASENAME1}_diff.html"
|
||||
|
||||
if [ -n "$OUTPUT_DIR" ]; then
|
||||
OUTPUT_FILE="$OUTPUT_DIR/${OUTPUT_BASENAME}"
|
||||
else
|
||||
FILE1_DIR=$(dirname "$FILE1")
|
||||
OUTPUT_FILE="$FILE1_DIR/${OUTPUT_BASENAME}"
|
||||
fi
|
||||
|
||||
echo " → Output file: $OUTPUT_FILE"
|
||||
|
||||
# Check if output file already exists and skip if not forcing overwrite
|
||||
if [ -f "$OUTPUT_FILE" ] && [ "$FORCE_OVERWRITE" = "false" ]; then
|
||||
echo " → Output file already exists, skipping (use -f to overwrite)"
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Load ZDDC configuration from first file's directory
|
||||
FILE1_DIR=$(dirname "$FILE1")
|
||||
load_zddc_config "$FILE1_DIR"
|
||||
|
||||
echo " → Loading ZDDC configuration from: $FILE1_DIR/zddc.conf"
|
||||
|
||||
# Determine template to use
|
||||
TEMPLATE_ABS=""
|
||||
if [ -n "$CUSTOM_TEMPLATE" ]; then
|
||||
if [ -f "$CUSTOM_TEMPLATE" ]; then
|
||||
TEMPLATE_ABS="$CUSTOM_TEMPLATE"
|
||||
echo " → Using custom template: $TEMPLATE_ABS"
|
||||
else
|
||||
echo " → Custom template not found: $CUSTOM_TEMPLATE"
|
||||
echo " → Falling back to default template"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for symlinked template in current directory
|
||||
if [ -z "$TEMPLATE_ABS" ] && [ -L "viewer-template.html" ]; then
|
||||
TEMPLATE_TARGET=$(readlink "viewer-template.html")
|
||||
if [ -f "$TEMPLATE_TARGET" ]; then
|
||||
TEMPLATE_ABS="$TEMPLATE_TARGET"
|
||||
echo " → Using template from symlink target: $TEMPLATE_ABS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for template in current directory
|
||||
if [ -z "$TEMPLATE_ABS" ] && [ -f "viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$(pwd)/viewer-template.html"
|
||||
echo " → Using template from current directory: $TEMPLATE_ABS"
|
||||
fi
|
||||
|
||||
# Resolve template to absolute path if it's relative
|
||||
if [ -n "$TEMPLATE_ABS" ] && [ "${TEMPLATE_ABS:0:1}" != "/" ]; then
|
||||
if [ -f "$TEMPLATE_ABS" ]; then
|
||||
TEMPLATE_ABS="$(pwd)/$TEMPLATE_ABS"
|
||||
elif [ -f "$SCRIPT_DIR/$TEMPLATE_ABS" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_DIR/$TEMPLATE_ABS"
|
||||
elif [ -f "$SCRIPT_TARGET_DIR/$TEMPLATE_ABS" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_TARGET_DIR/$TEMPLATE_ABS"
|
||||
elif [ -f "$SCRIPT_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_DIR/viewer-template.html"
|
||||
elif [ -f "$SCRIPT_TARGET_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_TARGET_DIR/viewer-template.html"
|
||||
fi
|
||||
elif [ -z "$TEMPLATE_ABS" ]; then
|
||||
# Fallback to script-relative template discovery
|
||||
if [ -f "$SCRIPT_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_DIR/viewer-template.html"
|
||||
elif [ -f "$SCRIPT_TARGET_DIR/viewer-template.html" ]; then
|
||||
TEMPLATE_ABS="$SCRIPT_TARGET_DIR/viewer-template.html"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create temp file for pandiff output
|
||||
TEMP_DIFF=$(mktemp)
|
||||
|
||||
# Create temp files for metadata extraction (unique per invocation)
|
||||
TEMP_METADATA_REV1=$(mktemp)
|
||||
TEMP_METADATA_REV2=$(mktemp)
|
||||
|
||||
echo "Stage 1: Generating diff with pandiff..."
|
||||
|
||||
# Run pandiff to generate HTML diff
|
||||
if ! pandiff --to html "$FILE1" "$FILE2" > "$TEMP_DIFF"; then
|
||||
echo " ✗ Failed to generate diff"
|
||||
rm -f "$TEMP_DIFF" "$TEMP_METADATA_REV1" "$TEMP_METADATA_REV2"
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ✓ Diff generated successfully"
|
||||
echo "Stage 2: Adding TOC and styling with pandoc..."
|
||||
|
||||
# Extract revision info from filenames for metadata
|
||||
REV_A=$(basename "$FILE1" .md | sed 's/.*_\([^_]*\)$/\1/')
|
||||
REV_B=$(basename "$FILE2" .md | sed 's/.*_\([^_]*\)$/\1/')
|
||||
|
||||
# Extract metadata from both files (safe - no eval, uses heredoc)
|
||||
{
|
||||
# Extract YAML frontmatter and parse fields safely
|
||||
awk '/^---$/{if(NR==1){p=1}else{p=0}} p && !/^---$/{print}' "$FILE1" > "$TEMP_METADATA_REV1"
|
||||
rev1_tracking_number=$(grep '^tracking_number:' "$TEMP_METADATA_REV1" | sed 's/^tracking_number: *"\(.*\)"$/\1/' | head -1)
|
||||
rev1_title=$(grep '^title:' "$TEMP_METADATA_REV1" | sed 's/^title: *"\(.*\)"$/\1/' | head -1)
|
||||
rev1_revision=$(grep '^revision:' "$TEMP_METADATA_REV1" | sed 's/^revision: *"\(.*\)"$/\1/' | head -1)
|
||||
rev1_status=$(grep '^status:' "$TEMP_METADATA_REV1" | sed 's/^status: *"\(.*\)"$/\1/' | head -1)
|
||||
rev1_project=$(grep '^project:' "$TEMP_METADATA_REV1" | sed 's/^project: *"\(.*\)"$/\1/' | head -1)
|
||||
rev1_date=$(grep '^date:' "$TEMP_METADATA_REV1" | sed 's/^date: *"\(.*\)"$/\1/' | head -1)
|
||||
}
|
||||
{
|
||||
awk '/^---$/{if(NR==1){p=1}else{p=0}} p && !/^---$/{print}' "$FILE2" > "$TEMP_METADATA_REV2"
|
||||
rev2_tracking_number=$(grep '^tracking_number:' "$TEMP_METADATA_REV2" | sed 's/^tracking_number: *"\(.*\)"$/\1/' | head -1)
|
||||
rev2_title=$(grep '^title:' "$TEMP_METADATA_REV2" | sed 's/^title: *"\(.*\)"$/\1/' | head -1)
|
||||
rev2_revision=$(grep '^revision:' "$TEMP_METADATA_REV2" | sed 's/^revision: *"\(.*\)"$/\1/' | head -1)
|
||||
rev2_status=$(grep '^status:' "$TEMP_METADATA_REV2" | sed 's/^status: *"\(.*\)"$/\1/' | head -1)
|
||||
rev2_project=$(grep '^project:' "$TEMP_METADATA_REV2" | sed 's/^project: *"\(.*\)"$/\1/' | head -1)
|
||||
rev2_date=$(grep '^date:' "$TEMP_METADATA_REV2" | sed 's/^date: *"\(.*\)"$/\1/' | head -1)
|
||||
}
|
||||
|
||||
# Clean up metadata temp files
|
||||
rm -f "$TEMP_METADATA_REV1" "$TEMP_METADATA_REV2"
|
||||
|
||||
# Generate diff-aware header HTML with view mode classes
|
||||
generate_diff_header() {
|
||||
local header_html=""
|
||||
|
||||
# Project title (should be same for both)
|
||||
header_html="<div class=\"header-line client-project\">$rev2_project (AR 28088)</div>"
|
||||
|
||||
# Document title with diff
|
||||
if [ "$rev1_title" != "$rev2_title" ]; then
|
||||
header_html="$header_html<div class=\"header-line document-title\"><del>$rev1_title</del><ins>$rev2_title</ins></div>"
|
||||
else
|
||||
header_html="$header_html<div class=\"header-line document-title\">$rev2_title</div>"
|
||||
fi
|
||||
|
||||
# Tracking number and revision with diff
|
||||
local tracking_rev_line=""
|
||||
if [ "$rev1_tracking_number" != "$rev2_tracking_number" ]; then
|
||||
tracking_rev_line="<del>$rev1_tracking_number</del><ins>$rev2_tracking_number</ins>"
|
||||
else
|
||||
tracking_rev_line="$rev1_tracking_number"
|
||||
fi
|
||||
|
||||
if [ "$rev1_revision" != "$rev2_revision" ]; then
|
||||
tracking_rev_line="$tracking_rev_line Revision: <del>$rev1_revision</del><ins>$rev2_revision</ins>"
|
||||
else
|
||||
tracking_rev_line="$tracking_rev_line Revision: $rev1_revision"
|
||||
fi
|
||||
|
||||
if [ "$rev1_status" != "$rev2_status" ]; then
|
||||
tracking_rev_line="$tracking_rev_line Status: <del>$rev1_status</del><ins>$rev2_status</ins>"
|
||||
else
|
||||
tracking_rev_line="$tracking_rev_line Status: $rev1_status"
|
||||
fi
|
||||
|
||||
header_html="$header_html<div class=\"header-line\">$tracking_rev_line</div>"
|
||||
|
||||
# Add draft marker if revision contains ~
|
||||
if echo "$rev2_revision" | grep -q "~"; then
|
||||
header_html="$header_html<div class=\"header-line metadata-line draft-line\"><span class=\"draft-status\">[DRAFT Generated at $(date '+%B %d, %Y at %I:%M:%S %p %Z')]</span></div>"
|
||||
fi
|
||||
|
||||
echo "$header_html"
|
||||
}
|
||||
|
||||
DIFF_HEADER_HTML=$(generate_diff_header)
|
||||
|
||||
# Generate timestamp for conversion
|
||||
GENERATION_TIME=$(date '+%B %d, %Y at %I:%M:%S %p %Z')
|
||||
|
||||
# Set resource path to second file directory for resource resolution
|
||||
FILE2_DIR=$(dirname "$FILE2")
|
||||
|
||||
# Escape HTML for safe shell usage
|
||||
ESCAPED_HEADER_HTML=$(printf '%s' "$DIFF_HEADER_HTML" | sed 's/"/\\"/g')
|
||||
|
||||
# Build pandoc command as array (not string with eval)
|
||||
PANDOC_ARGS=(
|
||||
"pandoc" "$TEMP_DIFF" "-o" "$OUTPUT_FILE"
|
||||
"--from" "html"
|
||||
"--standalone"
|
||||
"--template=$TEMPLATE_ABS"
|
||||
)
|
||||
|
||||
# Add TOC args if not disabled
|
||||
if [ "$NO_TOC" != "true" ]; then
|
||||
PANDOC_ARGS+=("--toc" "--toc-depth=3")
|
||||
fi
|
||||
|
||||
PANDOC_ARGS+=(
|
||||
"--css=$SCRIPT_DIR/custom.css"
|
||||
"--resource-path=$FILE2_DIR"
|
||||
"--metadata" "title=$rev2_title"
|
||||
"--metadata" "generation_time=$GENERATION_TIME"
|
||||
"--metadata" "diff_mode=true"
|
||||
"--metadata" "custom_header=$ESCAPED_HEADER_HTML"
|
||||
)
|
||||
|
||||
# Add ZDDC configuration variables from zddc.conf (only once)
|
||||
if [ -n "$client" ]; then
|
||||
PANDOC_ARGS+=("--variable" "client=$client")
|
||||
fi
|
||||
if [ -n "$project" ]; then
|
||||
PANDOC_ARGS+=("--variable" "project=$project")
|
||||
fi
|
||||
if [ -n "$contractor" ]; then
|
||||
PANDOC_ARGS+=("--variable" "contractor=$contractor")
|
||||
fi
|
||||
if [ -n "$project_number" ]; then
|
||||
PANDOC_ARGS+=("--variable" "project_number=$project_number")
|
||||
fi
|
||||
|
||||
# Pass TOC status to template
|
||||
if [ "$NO_TOC" = "true" ]; then
|
||||
PANDOC_ARGS+=("--variable" "no-toc=true")
|
||||
fi
|
||||
|
||||
PANDOC_ARGS+=("--section-divs" "--id-prefix=" "--html-q-tags")
|
||||
|
||||
# Execute pandoc via array (no eval)
|
||||
if "${PANDOC_ARGS[@]}"; then
|
||||
echo " ✓ HTML generated successfully"
|
||||
|
||||
echo "Stage 3: Adding diff view controls..."
|
||||
inject_diff_controls "$OUTPUT_FILE"
|
||||
|
||||
echo " ✓ Successfully created diff HTML: $(basename "$OUTPUT_FILE")"
|
||||
SUCCESSFUL=$((SUCCESSFUL + 1))
|
||||
else
|
||||
echo " ✗ Failed to convert diff to HTML"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
|
||||
# Clean up temp file
|
||||
rm -f "$TEMP_DIFF"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "DIFF CONVERSION SUMMARY"
|
||||
echo "=========================================="
|
||||
echo "Total pairs processed: $TOTAL_PAIRS"
|
||||
echo "Successful conversions: $SUCCESSFUL"
|
||||
echo "Failed conversions: $FAILED"
|
||||
echo "Skipped (existing files): $SKIPPED"
|
||||
echo "=========================================="
|
||||
163
pandoc/custom.css
Normal file
163
pandoc/custom.css
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* Legal-style heading numbering for ZDDC documents
|
||||
* Adds hierarchical numbering like 1, 1.1, 1.1.1, etc.
|
||||
*/
|
||||
|
||||
/* Reset counters at document level */
|
||||
.document-content {
|
||||
counter-reset: h1-counter;
|
||||
}
|
||||
|
||||
/* H1 counters */
|
||||
h1 {
|
||||
counter-reset: h2-counter h3-counter h4-counter h5-counter h6-counter;
|
||||
counter-increment: h1-counter;
|
||||
}
|
||||
|
||||
h1::before {
|
||||
content: counter(h1-counter) ". ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H2 counters */
|
||||
h2 {
|
||||
counter-reset: h3-counter h4-counter h5-counter h6-counter;
|
||||
counter-increment: h2-counter;
|
||||
}
|
||||
|
||||
h2::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H3 counters */
|
||||
h3 {
|
||||
counter-reset: h4-counter h5-counter h6-counter;
|
||||
counter-increment: h3-counter;
|
||||
}
|
||||
|
||||
h3::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H4 counters */
|
||||
h4 {
|
||||
counter-reset: h5-counter h6-counter;
|
||||
counter-increment: h4-counter;
|
||||
}
|
||||
|
||||
h4::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H5 counters */
|
||||
h5 {
|
||||
counter-reset: h6-counter;
|
||||
counter-increment: h5-counter;
|
||||
}
|
||||
|
||||
h5::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H6 counters */
|
||||
h6 {
|
||||
counter-increment: h6-counter;
|
||||
}
|
||||
|
||||
h6::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) "." counter(h6-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* TOC numbering to match document headings */
|
||||
.toc {
|
||||
counter-reset: toc-h1;
|
||||
}
|
||||
|
||||
.toc ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.toc > ul > li {
|
||||
counter-increment: toc-h1;
|
||||
counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6;
|
||||
}
|
||||
|
||||
.toc > ul > li > a::before {
|
||||
content: counter(toc-h1) ". ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li {
|
||||
counter-increment: toc-h2;
|
||||
counter-reset: toc-h3 toc-h4 toc-h5 toc-h6;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li > a::before {
|
||||
content: counter(toc-h1) "." counter(toc-h2) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li > ul > li {
|
||||
counter-increment: toc-h3;
|
||||
counter-reset: toc-h4 toc-h5 toc-h6;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li > ul > li > a::before {
|
||||
content: counter(toc-h1) "." counter(toc-h2) "." counter(toc-h3) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
/* Optional: Add some spacing after the numbers */
|
||||
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
/* Print-specific adjustments */
|
||||
@media print {
|
||||
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
|
||||
color: #000 !important; /* Ensure numbers print in black */
|
||||
}
|
||||
}
|
||||
|
||||
/* Optional: Style adjustments for better visual hierarchy */
|
||||
h1 {
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
padding-bottom: 0.3em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
/* Reduce margin for first heading */
|
||||
h1:first-of-type {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.2em;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 1.2em;
|
||||
}
|
||||
|
||||
h4, h5, h6 {
|
||||
margin-top: 1em;
|
||||
}
|
||||
351
pandoc/index.sh
Normal file
351
pandoc/index.sh
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Script to create symlinks and markdown table for ZDDC files
|
||||
# Usage: ./index.sh [-o output_dir] <folder1> [folder2] [...]
|
||||
#
|
||||
# Creates three types of symlinks in output directory:
|
||||
# 1. Full filename symlink
|
||||
# 2. trackingNumber.ext (latest revision)
|
||||
# 3. trackingNumber_revision.ext (specific revision)
|
||||
#
|
||||
# Also generates index.html in each input folder with summary table
|
||||
# CSS and title can be customized via YAML frontmatter in existing index.md files
|
||||
|
||||
set -e
|
||||
|
||||
cleanup() {
|
||||
unset latest_files
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Default output directory
|
||||
OUTPUT_DIR=".archive"
|
||||
|
||||
# Parse command line options
|
||||
while getopts "o:" opt; do
|
||||
case $opt in
|
||||
o)
|
||||
OUTPUT_DIR="$OPTARG"
|
||||
;;
|
||||
\?)
|
||||
echo "Invalid option: -$OPTARG" >&2
|
||||
echo "Usage: $0 [-o output_dir] <folder1> [folder2] [...]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Shift past the options
|
||||
shift $((OPTIND-1))
|
||||
|
||||
# Check if at least one folder is provided
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 [-o output_dir] <folder1> [folder2] [...]"
|
||||
echo "Creates symlinks and markdown table for ZDDC files"
|
||||
echo "Options:"
|
||||
echo " -o output_dir Output directory (default: .archive)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate all input directories exist
|
||||
for folder in "$@"; do
|
||||
if [ ! -d "$folder" ]; then
|
||||
echo "Error: Directory '$folder' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Create output directory
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Function to get relative path from $1 (base dir) to $2 (target path)
|
||||
# Uses Python for portability (works on both GNU and BSD systems)
|
||||
relative_path() {
|
||||
local base_dir="$1"
|
||||
local target_path="$2"
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c "import os; print(os.path.relpath('$target_path', '$base_dir'))"
|
||||
else
|
||||
# Fallback: use absolute paths if python3 not available
|
||||
realpath "$target_path"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to get revision from symlink target filename
|
||||
get_revision_from_target() {
|
||||
local target="$1"
|
||||
local basename_target=$(basename "$target")
|
||||
|
||||
# Parse ZDDC filename to extract revision
|
||||
if [[ "$basename_target" =~ ^([^_]+)_([^\ ]+)\ +\(([^\)]+)\)\ *-\ *(.+)\.([^.]+)$ ]]; then
|
||||
echo "${BASH_REMATCH[2]}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check if revision A is greater than revision B
|
||||
is_revision_greater() {
|
||||
local rev_a="$1"
|
||||
local rev_b="$2"
|
||||
|
||||
# Remove tilde prefix for comparison
|
||||
local clean_a="${rev_a#~}"
|
||||
local clean_b="${rev_b#~}"
|
||||
|
||||
# Use version sort to compare
|
||||
[ "$(printf '%s\n%s\n' "$clean_b" "$clean_a" | sort -V | tail -n1)" = "$clean_a" ] && [ "$clean_a" != "$clean_b" ]
|
||||
}
|
||||
|
||||
echo "Processing ZDDC files from folders: $*"
|
||||
echo "Output directory: $OUTPUT_DIR"
|
||||
|
||||
|
||||
# Process each folder individually
|
||||
for folder in "$@"; do
|
||||
echo "Processing folder: $folder"
|
||||
|
||||
# Track latest revisions for each tracking number (per folder)
|
||||
declare -A latest_files
|
||||
|
||||
# Sequential counter for table rows (per folder)
|
||||
row_counter=0
|
||||
|
||||
# Set title for this folder
|
||||
title="Document Index - $(basename "$folder")"
|
||||
|
||||
# Initialize markdown file with CSS and title for this folder
|
||||
index_md_file="$folder/index.md"
|
||||
|
||||
# Check if index.md exists without sentinel comment
|
||||
sentinel="<!-- Generated by zddc index.sh -->"
|
||||
if [ -f "$index_md_file" ]; then
|
||||
if ! grep -qF "$sentinel" "$index_md_file" 2>/dev/null; then
|
||||
echo " Warning: $index_md_file already exists and was not generated by this script"
|
||||
echo " Backing up to ${index_md_file}.bak"
|
||||
mv "$index_md_file" "${index_md_file}.bak"
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > "$index_md_file" <<EOF
|
||||
<!-- Generated by zddc index.sh -->
|
||||
|
||||
---
|
||||
title: "$title"
|
||||
---
|
||||
|
||||
<style>
|
||||
body {
|
||||
max-width: none !important;
|
||||
margin: 20px !important;
|
||||
font-family: Arial, sans-serif !important;
|
||||
}
|
||||
table {
|
||||
width: 100% !important;
|
||||
border-collapse: collapse !important;
|
||||
table-layout: auto !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px !important;
|
||||
vertical-align: top !important;
|
||||
border: 1px solid #ccc !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
th {
|
||||
background-color: #f0f0f0 !important;
|
||||
font-weight: bold !important;
|
||||
}
|
||||
th:nth-child(1), td:nth-child(1) {
|
||||
width: 1% !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
th:nth-child(3), td:nth-child(3) {
|
||||
white-space: normal !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
th:nth-child(4), td:nth-child(4) {
|
||||
text-align: center !important;
|
||||
}
|
||||
th:nth-child(5), td:nth-child(5) {
|
||||
text-align: center !important;
|
||||
}
|
||||
th:nth-child(6), td:nth-child(6) {
|
||||
text-align: center !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
| # | TRACKING NO | TITLE | REV | STATUS | SHA256 |
|
||||
|---|---|---|---|---|---|
|
||||
EOF
|
||||
|
||||
# Find all files in current folder
|
||||
while IFS= read -r -d '' file; do
|
||||
filename=$(basename "$file")
|
||||
|
||||
# Skip index.md and index.html files
|
||||
if [[ "$filename" == "index.md" || "$filename" == "index.html" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Parse ZDDC filename: trackingNumber_revision (status) - title.extension
|
||||
# Skip files that don't match ZDDC format
|
||||
if [[ ! "$filename" =~ ^([^_]+)_([^\ ]+)\ +\(([^\)]+)\)\ *-\ *(.+)\.([^.]+)$ ]]; then
|
||||
echo " Skipping non-ZDDC file: $filename"
|
||||
continue
|
||||
fi
|
||||
|
||||
tracking_number="${BASH_REMATCH[1]}"
|
||||
revision="${BASH_REMATCH[2]}"
|
||||
status="${BASH_REMATCH[3]}"
|
||||
doc_title="${BASH_REMATCH[4]}"
|
||||
extension="${BASH_REMATCH[5]}"
|
||||
|
||||
# Remove tilde prefix from revision for comparison (draft indicator)
|
||||
clean_revision="${revision#~}"
|
||||
|
||||
# Calculate SHA256
|
||||
sha256=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
|
||||
# Create full filename symlink (always safe to overwrite)
|
||||
ln -sf "$(relative_path "$OUTPUT_DIR" "$file")" "$OUTPUT_DIR/$filename"
|
||||
|
||||
# Handle specific revision symlink with conflict detection
|
||||
specific_name="${tracking_number}_${revision}.${extension}"
|
||||
specific_path="$OUTPUT_DIR/$specific_name"
|
||||
|
||||
if [ -L "$specific_path" ]; then
|
||||
# Symlink exists, check if it's the same file
|
||||
existing_target=$(readlink "$specific_path")
|
||||
existing_absolute=$(realpath "$OUTPUT_DIR/$existing_target" 2>/dev/null || echo "")
|
||||
current_absolute=$(realpath "$file")
|
||||
|
||||
if [ "$existing_absolute" != "$current_absolute" ]; then
|
||||
# Different files claiming same revision - check SHA256
|
||||
if [ -f "$existing_absolute" ]; then
|
||||
existing_sha256=$(sha256sum "$existing_absolute" | cut -d' ' -f1)
|
||||
if [ "$existing_sha256" != "$sha256" ]; then
|
||||
echo " ERROR: Revision conflict for $specific_name"
|
||||
echo " Existing: $existing_absolute (SHA256: $existing_sha256)"
|
||||
echo " New: $current_absolute (SHA256: $sha256)"
|
||||
echo " Different files claim to be the same revision. Skipping."
|
||||
continue
|
||||
else
|
||||
echo " Duplicate file detected for $specific_name (same SHA256), skipping symlink update"
|
||||
fi
|
||||
else
|
||||
echo " Warning: Existing symlink target not found, updating: $specific_name"
|
||||
ln -sf "$(relative_path "$OUTPUT_DIR" "$file")" "$specific_path"
|
||||
fi
|
||||
else
|
||||
echo " Symlink already points to same file: $specific_name"
|
||||
fi
|
||||
else
|
||||
# No existing symlink, create it
|
||||
ln -sf "$(relative_path "$OUTPUT_DIR" "$file")" "$specific_path"
|
||||
fi
|
||||
|
||||
# Track latest revision for each tracking number
|
||||
current_latest="${latest_files[$tracking_number]}"
|
||||
if [ -z "$current_latest" ]; then
|
||||
latest_files["$tracking_number"]="$file|$clean_revision"
|
||||
else
|
||||
current_rev="${current_latest#*|}"
|
||||
if is_revision_greater "$clean_revision" "$current_rev"; then
|
||||
latest_files["$tracking_number"]="$file|$clean_revision"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increment row counter
|
||||
row_counter=$((row_counter + 1))
|
||||
|
||||
# Create hyperlinks relative to output directory from this folder
|
||||
rel_path_to_output=$(relative_path "$folder" "$OUTPUT_DIR")
|
||||
tracking_link="[$tracking_number]($rel_path_to_output/$tracking_number.$extension)"
|
||||
revision_link="[$revision]($rel_path_to_output/${tracking_number}_${revision}.$extension)"
|
||||
|
||||
# Create truncated SHA256 for display
|
||||
sha256_short="${sha256:0:6}...${sha256: -6}"
|
||||
|
||||
# Add to markdown table
|
||||
echo "| $row_counter | $tracking_link | $doc_title | $revision_link | $status | <span class=\"sha256\" title=\"$sha256\">$sha256_short</span> |" >> "$index_md_file"
|
||||
|
||||
echo " $filename -> symlinks created"
|
||||
done < <(find "$folder" -maxdepth 1 \( -type f -o -type l \) -print0)
|
||||
|
||||
# Create/update latest revision symlinks for this folder
|
||||
echo " Creating/updating latest revision symlinks..."
|
||||
for tracking_number in "${!latest_files[@]}"; do
|
||||
file_info="${latest_files[$tracking_number]}"
|
||||
file="${file_info%|*}"
|
||||
new_revision="${file_info#*|}"
|
||||
filename=$(basename "$file")
|
||||
|
||||
# Extract extension
|
||||
extension="${filename##*.}"
|
||||
|
||||
# Check latest symlink
|
||||
latest_name="${tracking_number}.${extension}"
|
||||
latest_path="$OUTPUT_DIR/$latest_name"
|
||||
|
||||
should_update=true
|
||||
|
||||
if [ -L "$latest_path" ]; then
|
||||
# Existing latest symlink - check revision
|
||||
existing_target=$(readlink "$latest_path")
|
||||
existing_absolute=$(realpath "$OUTPUT_DIR/$existing_target" 2>/dev/null || echo "")
|
||||
|
||||
if [ -f "$existing_absolute" ]; then
|
||||
existing_revision=$(get_revision_from_target "$existing_absolute")
|
||||
|
||||
if [ -n "$existing_revision" ]; then
|
||||
if is_revision_greater "$new_revision" "$existing_revision"; then
|
||||
echo " Updating latest: $latest_name ($existing_revision -> $new_revision)"
|
||||
else
|
||||
echo " Keeping existing latest: $latest_name (current: $existing_revision >= new: $new_revision)"
|
||||
should_update=false
|
||||
fi
|
||||
else
|
||||
echo " Warning: Could not parse revision from existing target, updating: $latest_name"
|
||||
fi
|
||||
else
|
||||
echo " Warning: Existing latest symlink target not found, updating: $latest_name"
|
||||
fi
|
||||
else
|
||||
echo " Creating new latest: $latest_name -> $filename"
|
||||
fi
|
||||
|
||||
if [ "$should_update" = true ]; then
|
||||
ln -sf "$(relative_path "$OUTPUT_DIR" "$file")" "$latest_path"
|
||||
fi
|
||||
done
|
||||
|
||||
# Convert markdown to HTML with pandoc for this folder
|
||||
echo " Converting to HTML..."
|
||||
if command -v pandoc >/dev/null 2>&1; then
|
||||
pandoc "$index_md_file" -o "$folder/index.html" \
|
||||
--standalone \
|
||||
--embed-resources \
|
||||
--from markdown+raw_html
|
||||
|
||||
echo " Markdown file: $folder/index.md"
|
||||
echo " HTML file: $folder/index.html"
|
||||
else
|
||||
echo " Warning: pandoc not found, skipping HTML conversion"
|
||||
echo " Markdown file: $folder/index.md"
|
||||
fi
|
||||
|
||||
# Reset associative array for next folder
|
||||
unset latest_files
|
||||
done
|
||||
|
||||
# Count symlinks
|
||||
symlink_count=$(find "$OUTPUT_DIR" -type l | wc -l)
|
||||
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
echo " Output directory: $OUTPUT_DIR"
|
||||
echo " Symlinks created: $symlink_count"
|
||||
for folder in "$@"; do
|
||||
echo " Markdown file: $folder/index.md"
|
||||
echo " HTML file: $folder/index.html"
|
||||
done
|
||||
1226
pandoc/viewer-template.html
Normal file
1226
pandoc/viewer-template.html
Normal file
File diff suppressed because it is too large
Load diff
18
pandoc/zddc.conf
Normal file
18
pandoc/zddc.conf
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# zddc.conf - ZDDC project configuration
|
||||
# Place this file in your project directory. It is sourced by the convert and
|
||||
# convert-diff scripts to inject project-level metadata into the document header.
|
||||
#
|
||||
# Variables defined here are passed to pandoc as --variable arguments and
|
||||
# are available in the viewer template as $client$, $project$, etc.
|
||||
|
||||
# Name of the contracting organization (appears in document header)
|
||||
contractor="Contractor Name"
|
||||
|
||||
# Client organization name (appears in document header as "Client - Project")
|
||||
client="Client Name"
|
||||
|
||||
# Full project name (appears in document header paired with client)
|
||||
project="Project Name"
|
||||
|
||||
# Project number or identifier (appears in parentheses after project name)
|
||||
project_number="Project Number"
|
||||
59
playwright.config.js
Normal file
59
playwright.config.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
retries: 0,
|
||||
reporter: [['line'], ['html', { open: 'never' }]],
|
||||
|
||||
use: {
|
||||
// Chromium only -- File System Access API requires it,
|
||||
// and the ZDDC tools target "any modern Chromium-based browser"
|
||||
browserName: 'chromium',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'archive',
|
||||
testMatch: 'archive.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'transmittal',
|
||||
testMatch: 'transmittal.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'transmittal-init',
|
||||
testMatch: 'transmittal-init-check.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'transmittal-drag-drop',
|
||||
testMatch: 'transmittal-drag-drop.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'classifier',
|
||||
testMatch: 'classifier.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'mdedit',
|
||||
testMatch: 'mdedit.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'zddc',
|
||||
testMatch: 'zddc.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'zddc-filter',
|
||||
testMatch: 'zddc-filter.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'build-label',
|
||||
testMatch: 'build-label.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'schema',
|
||||
testMatch: 'schema.spec.js',
|
||||
},
|
||||
],
|
||||
});
|
||||
490
shared/base.css
Normal file
490
shared/base.css
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
/* ==========================================================================
|
||||
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);
|
||||
}
|
||||
198
shared/build-lib.sh
Executable file
198
shared/build-lib.sh
Executable file
|
|
@ -0,0 +1,198 @@
|
|||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# ZDDC shared build helpers — sourced by each tool's build.sh
|
||||
#
|
||||
# Usage in a tool build.sh:
|
||||
# root_dir=$(cd "$(dirname "$0")" && pwd)
|
||||
# . "$root_dir/../shared/build-lib.sh"
|
||||
#
|
||||
# Provides:
|
||||
# ensure_exists <path> — abort with error if file missing
|
||||
# concat_files <file ...> — cat each relative path under $root_dir
|
||||
# build_timestamp — ISO UTC timestamp string, set at source time;
|
||||
# used as build_label for dev builds
|
||||
# escape_js_close_tags <in> <out>
|
||||
# — copy <in> to <out> with all '</' rewritten as
|
||||
# '<\/' so the HTML parser cannot misread the
|
||||
# inlined JS as containing a closing </script>.
|
||||
# The JS engine treats \/ as a regular slash,
|
||||
# so runtime behaviour is unchanged.
|
||||
# compute_build_label <tool> [--release [<channel-or-version>]]
|
||||
# — sets globals: build_label, build_version,
|
||||
# is_release, is_red, channel.
|
||||
# See "Channels and release args" below.
|
||||
# promote_release <tool> — write to website/releases/ in the layout
|
||||
# driven by $channel and $build_version. For
|
||||
# stable, also update the _latest.html
|
||||
# symlink and create the git tag.
|
||||
#
|
||||
# Channels and release args:
|
||||
# <none> dev build, dist/ only, label "Built: <ts> BETA" (red).
|
||||
# --release stable, auto-bump patch from latest tag (or 0.0.1).
|
||||
# --release X.Y.Z stable, explicit version.
|
||||
# --release alpha alpha channel; mutable file, no tag, label "alpha · <date> · <sha>" (red).
|
||||
# --release beta beta channel; analogous to alpha.
|
||||
# --release <other> error.
|
||||
# =============================================================================
|
||||
|
||||
# Abort if root_dir is not set by the caller
|
||||
if [ -z "${root_dir:-}" ]; then
|
||||
echo "build-lib.sh: root_dir must be set before sourcing this file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail hard on any missing source file
|
||||
ensure_exists() {
|
||||
_path="$1"
|
||||
if [ ! -f "$_path" ]; then
|
||||
echo "error: missing file: $_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Concatenate files listed as positional args, each relative to root_dir
|
||||
concat_files() {
|
||||
for _rel do
|
||||
ensure_exists "$root_dir/$_rel"
|
||||
cat "$root_dir/$_rel"
|
||||
printf '\n'
|
||||
done
|
||||
}
|
||||
|
||||
# ISO UTC build timestamp — set once when this file is sourced
|
||||
build_timestamp=$(date -u +"%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Rewrite '</script' (case-insensitive) in JS as '<\/script' so the HTML parser
|
||||
# cannot mistake string contents for a closing </script> tag. Per the HTML5 spec
|
||||
# only </script terminates a <script> block — other tags like </div> are safe
|
||||
# inside a script's text content. Narrowly targeting </script avoids corrupting
|
||||
# regex literals like /</g whose trailing letter is a flag, not a tag name.
|
||||
# The JS engine treats '\/' the same as '/' inside a string, so behaviour is
|
||||
# unchanged. See ARCHITECTURE.md "HTML Embedding Safety".
|
||||
escape_js_close_tags() {
|
||||
sed 's#</\([sS][cC][rR][iI][pP][tT]\)#<\\/\1#g' "$1" > "$2"
|
||||
}
|
||||
|
||||
# Validate that $1 is a strict X.Y.Z numeric version, where each component
|
||||
# is a non-empty numeric string. Exits with an error if not.
|
||||
_validate_semver() {
|
||||
_v="$1"
|
||||
_bad() {
|
||||
echo "error: invalid release argument: '$_v' (expected: alpha, beta, or X.Y.Z stable version)" >&2
|
||||
exit 1
|
||||
}
|
||||
_v1="${_v%%.*}"
|
||||
_rest="${_v#*.}"
|
||||
[ "$_rest" = "$_v" ] && _bad
|
||||
_v2="${_rest%%.*}"
|
||||
_v3="${_rest#*.}"
|
||||
{ [ "$_v3" = "$_rest" ] || [ "$_v3" != "${_v3%.*}" ]; } && _bad
|
||||
case "$_v1" in '' | *[!0-9]*) _bad ;; esac
|
||||
case "$_v2" in '' | *[!0-9]*) _bad ;; esac
|
||||
case "$_v3" in '' | *[!0-9]*) _bad ;; esac
|
||||
}
|
||||
|
||||
# Compute build label and channel. Reads positional args:
|
||||
# compute_build_label <tool_name> [--release [<channel-or-version>]]
|
||||
# Sets global variables:
|
||||
# build_label — text rendered into the page's {{BUILD_LABEL}} slot
|
||||
# build_version — bare semver string (stable releases only)
|
||||
# is_release — "1" for any --release invocation, else "0"
|
||||
# is_red — "1" if the label should render red+bold (dev/alpha/beta), else "0"
|
||||
# channel — "stable" / "alpha" / "beta" / "" (dev)
|
||||
compute_build_label() {
|
||||
_tool="$1"
|
||||
_flag="${2:-}"
|
||||
_arg="${3:-}"
|
||||
|
||||
is_release=0
|
||||
is_red=1
|
||||
channel=""
|
||||
build_version=""
|
||||
|
||||
if [ "$_flag" != "--release" ]; then
|
||||
build_label="Built: ${build_timestamp} BETA"
|
||||
return 0
|
||||
fi
|
||||
|
||||
is_release=1
|
||||
|
||||
case "$_arg" in
|
||||
alpha | beta)
|
||||
channel="$_arg"
|
||||
_date=$(date -u +"%Y-%m-%d")
|
||||
_sha=$(git -C "$root_dir" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown")
|
||||
build_label="${channel} · ${_date} · ${_sha}"
|
||||
return 0
|
||||
;;
|
||||
'')
|
||||
# Auto-bump patch from latest stable tag for this tool.
|
||||
_latest=$(git -C "$root_dir" tag --list "${_tool}-v*" --sort=-v:refname 2>/dev/null | head -1)
|
||||
if [ -z "$_latest" ]; then
|
||||
build_version="0.0.1"
|
||||
else
|
||||
_ver="${_latest#${_tool}-v}"
|
||||
_major="${_ver%%.*}"
|
||||
_rest="${_ver#*.}"
|
||||
_minor="${_rest%%.*}"
|
||||
_patch="${_rest#*.}"
|
||||
_patch=$((_patch + 1))
|
||||
build_version="${_major}.${_minor}.${_patch}"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
_validate_semver "$_arg"
|
||||
build_version="$_arg"
|
||||
;;
|
||||
esac
|
||||
|
||||
channel="stable"
|
||||
is_red=0
|
||||
build_label="v${build_version}"
|
||||
}
|
||||
|
||||
# Promote a built dist file to the appropriate slot under website/releases/.
|
||||
# Reads from caller scope: $channel, $build_version, $output_html, $root_dir.
|
||||
#
|
||||
# Stable releases write website/releases/<tool>_v<version>.html, refresh the
|
||||
# website/releases/<tool>_latest.html symlink, and tag <tool>-v<version> in
|
||||
# git. Skips silently when the source has not changed since the latest tag.
|
||||
#
|
||||
# Alpha and beta channel releases overwrite website/releases/<tool>_<channel>.html
|
||||
# in place with no tag (the embedded label carries date + commit SHA, so the
|
||||
# source is recoverable from git directly).
|
||||
promote_release() {
|
||||
_tool="$1"
|
||||
_releases_dir="$root_dir/../website/releases"
|
||||
mkdir -p "$_releases_dir"
|
||||
|
||||
if [ "$channel" = "alpha" ] || [ "$channel" = "beta" ]; then
|
||||
_dest="${_releases_dir}/${_tool}_${channel}.html"
|
||||
cp "$output_html" "$_dest"
|
||||
echo "Released $channel to $_dest"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$channel" != "stable" ] || [ -z "$build_version" ]; then
|
||||
echo "promote_release: refusing to promote — channel=$channel build_version=$build_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
_latest=$(git -C "$root_dir" tag --list "${_tool}-v*" --sort=-v:refname 2>/dev/null | head -1)
|
||||
if [ -n "$_latest" ] && git -C "$root_dir" diff --quiet "$_latest" HEAD -- . ../shared 2>/dev/null; then
|
||||
echo "${_tool}: no source changes since $_latest — skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
_versioned="${_releases_dir}/${_tool}_v${build_version}.html"
|
||||
cp "$output_html" "$_versioned"
|
||||
echo "Released $_versioned"
|
||||
|
||||
# Symlink target is relative to its own directory so the link survives
|
||||
# path moves and works regardless of where the website is mounted.
|
||||
(cd "$_releases_dir" && ln -sfn "${_tool}_v${build_version}.html" "${_tool}_latest.html")
|
||||
echo "Updated ${_tool}_latest.html -> ${_tool}_v${build_version}.html"
|
||||
|
||||
git -C "$root_dir" tag "${_tool}-v${build_version}"
|
||||
echo "Tagged ${_tool}-v${build_version} — run: git push --tags"
|
||||
}
|
||||
94
shared/hash.js
Normal file
94
shared/hash.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* 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);
|
||||
46
shared/help.js
Normal file
46
shared/help.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
}());
|
||||
84
shared/theme.js
Normal file
84
shared/theme.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
}());
|
||||
9
shared/zddc-filter-test.html
Normal file
9
shared/zddc-filter-test.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>zddc.filter test shim</title></head>
|
||||
<body>
|
||||
<!-- Loads shared/zddc.js then shared/zddc-filter.js so Playwright tests can call window.zddc.filter.* -->
|
||||
<script src="zddc.js"></script>
|
||||
<script src="zddc-filter.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
160
shared/zddc-filter.js
Normal file
160
shared/zddc-filter.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
(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 };
|
||||
})();
|
||||
9
shared/zddc-test.html
Normal file
9
shared/zddc-test.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>ZDDC library test shim</title></head>
|
||||
<body>
|
||||
<!-- Loads shared/zddc.js + shared/hash.js so Playwright tests can call window.zddc.* -->
|
||||
<script src="zddc.js"></script>
|
||||
<script src="hash.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
380
shared/zddc.js
Normal file
380
shared/zddc.js
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
/**
|
||||
* 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));
|
||||
113
tests/archive.spec.js
Normal file
113
tests/archive.spec.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { MOCK_FS_INIT_SCRIPT } from './fixtures/mock-fs-api.js';
|
||||
import * as path from 'path';
|
||||
|
||||
const HTML_PATH = path.resolve('archive/dist/archive.html');
|
||||
|
||||
test.describe('Archive Browser', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(MOCK_FS_INIT_SCRIPT);
|
||||
});
|
||||
|
||||
test('loads without errors', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#appContainer', { timeout: 15000 });
|
||||
|
||||
// Page title contains "Archive"
|
||||
await expect(page).toHaveTitle(/Archive/i);
|
||||
|
||||
// No-directory message is shown before any directory is opened
|
||||
await expect(page.locator('#noDirectoryMessage')).toBeVisible();
|
||||
|
||||
// The open-directory button is present
|
||||
await expect(page.locator('#addDirectoryBtn')).toBeVisible();
|
||||
});
|
||||
|
||||
test('scans a mock directory tree and displays files', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
// Log to see if the scan is working
|
||||
page.on('console', msg => {
|
||||
console.log('Browser console:', msg.text());
|
||||
});
|
||||
page.on('pageerror', err => {
|
||||
console.log('Page error:', err.message);
|
||||
});
|
||||
|
||||
// The archive expects a two-level structure: root → transmittal-folder → files.
|
||||
// Flat files at the root are not counted — they must be inside subdirectories.
|
||||
await page.evaluate(() => {
|
||||
window.__setMockDirectoryTree('test-project', {
|
||||
'2025-01-15_123456-EM-TRN-0001 (IFC) - First Transmittal': {
|
||||
'123456-EL-SPC-2623_A (IFC) - Specification.pdf': '%PDF',
|
||||
'123456-EL-DRW-0001_B (IFR) - Drawing.dwg': 'DWG',
|
||||
},
|
||||
'2025-02-10_123456-EM-TRN-0002 (IFC) - Second Transmittal': {
|
||||
'789012-ME-CAL-0001_A (IFA) - Calculation.pdf': '%PDF',
|
||||
},
|
||||
});
|
||||
console.log('Mock directory set up');
|
||||
});
|
||||
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
|
||||
// Wait for scanning to complete
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Log state for debugging
|
||||
await page.evaluate(() => {
|
||||
console.log('After scanning:');
|
||||
console.log(' window.app exists:', typeof window.app !== 'undefined');
|
||||
if (typeof window.app !== 'undefined') {
|
||||
console.log(' window.app type:', typeof window.app);
|
||||
console.log(' window.app constructor:', window.app.constructor.name);
|
||||
console.log(' window.app.directories:', Array.isArray(window.app.directories) ? window.app.directories.length : window.app.directories);
|
||||
console.log(' window.app.files:', Array.isArray(window.app.files) ? window.app.files.length : window.app.files);
|
||||
console.log(' window.app.filteredFiles:', Array.isArray(window.app.filteredFiles) ? window.app.filteredFiles.length : window.app.filteredFiles);
|
||||
console.log(' window.app modules:', Object.keys(window.app || {}).filter(k => k.startsWith('modules')));
|
||||
} else {
|
||||
console.log(' window.app is undefined!');
|
||||
}
|
||||
});
|
||||
|
||||
// Select all grouping folders so files are included in the file list
|
||||
await page.evaluate(() => {
|
||||
const cb = document.getElementById('selectAllGroupingCheckbox');
|
||||
if (cb && !cb.checked) cb.click();
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// The files table should have at least one data row
|
||||
const rowCount = await page.locator('#filesTableBody tr').count();
|
||||
expect(rowCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Status bar should show files were found (any non-empty text is fine)
|
||||
const fileCountText = await page.locator('#fileCount').textContent();
|
||||
expect(fileCountText).toBeTruthy();
|
||||
});
|
||||
|
||||
test('parser module uses shared zddc helpers (not its own wrappers)', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#appContainer', { timeout: 15000 });
|
||||
|
||||
const probe = await page.evaluate(() => ({
|
||||
// archive's parser module should NOT re-export wrappers we removed
|
||||
hasParseFileNameWrapper: typeof window.app.modules.parser?.parseFileName === 'function',
|
||||
hasParseRevisionWrapper: typeof window.app.modules.parser?.parseRevision === 'function',
|
||||
// but should still expose archive-specific helpers
|
||||
hasIsTransmittalFolder: typeof window.app.modules.parser?.isTransmittalFolder === 'function',
|
||||
hasGroupFiles: typeof window.app.modules.parser?.groupFilesByTrackingNumber === 'function',
|
||||
// and isTransmittalFolder should agree with shared zddc.parseFolder
|
||||
validFolder: window.app.modules.parser.isTransmittalFolder('2025-10-31_123456-EM-SUB-0001 (IFR) - General Arrangement'),
|
||||
invalidFolder: window.app.modules.parser.isTransmittalFolder('not-a-zddc-folder'),
|
||||
}));
|
||||
|
||||
expect(probe.hasParseFileNameWrapper).toBe(false);
|
||||
expect(probe.hasParseRevisionWrapper).toBe(false);
|
||||
expect(probe.hasIsTransmittalFolder).toBe(true);
|
||||
expect(probe.hasGroupFiles).toBe(true);
|
||||
expect(probe.validFolder).toBe(true);
|
||||
expect(probe.invalidFolder).toBe(false);
|
||||
});
|
||||
});
|
||||
64
tests/build-label.spec.js
Normal file
64
tests/build-label.spec.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* build-label.spec.js
|
||||
*
|
||||
* Verifies that the {{BUILD_LABEL}} placeholder is correctly substituted in all
|
||||
* four tool dist and website files:
|
||||
*
|
||||
* - No placeholder text leaks through
|
||||
* - The .build-timestamp element is present and visible in the browser
|
||||
* - The label content is either a timestamp ("Built: 20...") or a version ("v...")
|
||||
* - website/ files (when present) always show the version format
|
||||
*
|
||||
* Note: dist/ files may show either format depending on whether the last build
|
||||
* was a dev build (timestamp) or a release build (version). Both are valid.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const tools = ['archive', 'transmittal', 'classifier', 'mdedit'];
|
||||
|
||||
for (const tool of tools) {
|
||||
const distPath = path.resolve(`${tool}/dist/${tool}.html`);
|
||||
const websitePath = path.resolve(`website/${tool}.html`);
|
||||
|
||||
test.describe(`${tool}: build-label`, () => {
|
||||
|
||||
test(`dist file: no placeholder text leaks through`, async () => {
|
||||
const html = fs.readFileSync(distPath, 'utf8');
|
||||
expect(html).not.toContain('{{BUILD_LABEL}}');
|
||||
expect(html).not.toContain('{{BUILD_TIMESTAMP}}');
|
||||
});
|
||||
|
||||
test(`dist file: .build-timestamp element is visible in browser`, async ({ page }) => {
|
||||
const waitUntil = tool === 'mdedit' ? 'load' : 'domcontentloaded';
|
||||
await page.goto(`file://${distPath}`, { waitUntil });
|
||||
const el = page.locator('.build-timestamp');
|
||||
await expect(el).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test(`dist file: label is a valid timestamp or version string`, async () => {
|
||||
const html = fs.readFileSync(distPath, 'utf8');
|
||||
// BETA labels are wrapped in an inner <span style="color:red...">; non-BETA are bare text.
|
||||
const match = html.match(/class="build-timestamp">(?:<span[^>]*>)?([^<]+?)(?:<\/span>)?</);
|
||||
expect(match, 'build-timestamp element must have text content').toBeTruthy();
|
||||
const label = match[1];
|
||||
const isTimestamp = /^Built: 20\d\d-\d\d-\d\d \d\d:\d\d:\d\d( BETA)?$/.test(label);
|
||||
const isVersion = /^v\d+\.\d+\.\d+( BETA)?$/.test(label);
|
||||
expect(isTimestamp || isVersion,
|
||||
`Expected timestamp or version, got: "${label}"`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test(`website file: label shows version format (release build)`, async () => {
|
||||
if (!fs.existsSync(websitePath)) {
|
||||
test.skip(true, `website/${tool}.html not present — run sh ${tool}/build.sh --release first`);
|
||||
return;
|
||||
}
|
||||
const html = fs.readFileSync(websitePath, 'utf8');
|
||||
expect(html).not.toContain('{{BUILD_LABEL}}');
|
||||
expect(html).not.toContain('{{BUILD_TIMESTAMP}}');
|
||||
expect(html).toMatch(/class="build-timestamp">(?:<span[^>]*>)?v\d+\.\d+\.\d+( BETA)?(?:<\/span>)?</);
|
||||
});
|
||||
});
|
||||
}
|
||||
112
tests/classifier.spec.js
Normal file
112
tests/classifier.spec.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import * as path from 'path';
|
||||
|
||||
const HTML_PATH = path.resolve('classifier/dist/classifier.html');
|
||||
|
||||
test.describe('Classifier', () => {
|
||||
|
||||
test('loads without errors', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Wait for app initialisation
|
||||
await page.waitForFunction(() => typeof window.app !== 'undefined' && window.app.modules, { timeout: 15000 });
|
||||
|
||||
// Core modules should be registered
|
||||
const moduleNames = await page.evaluate(() => Object.keys(window.app.modules));
|
||||
expect(moduleNames).toContain('store');
|
||||
expect(moduleNames).toContain('spreadsheet');
|
||||
expect(moduleNames).toContain('validator');
|
||||
});
|
||||
|
||||
test('files injected into store are parsed and produce valid computed filenames', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.app !== 'undefined' && window.app.modules?.store, { timeout: 15000 });
|
||||
|
||||
// Inject files via the store API. The main app panel stays hidden until a real
|
||||
// directory is opened via showDirectoryPicker, so we verify through the store
|
||||
// and spreadsheet module APIs rather than the DOM table.
|
||||
const result = await page.evaluate(() => {
|
||||
// File objects must match the shape the scanner produces:
|
||||
// `trackingNumber`, `originalFilename`, `extension` (with dot)
|
||||
const files = [
|
||||
{ trackingNumber: '123456-EL-SPC-2623', title: 'Specification For Switchgear',
|
||||
revision: 'A', status: 'IFC', extension: '.pdf',
|
||||
originalFilename: '123456-EL-SPC-2623_A (IFC) - Specification For Switchgear',
|
||||
name: '123456-EL-SPC-2623_A (IFC) - Specification For Switchgear.pdf',
|
||||
path: 'test-project/123456-EL-SPC-2623_A (IFC) - Specification For Switchgear.pdf',
|
||||
size: 45000, isDirectory: false },
|
||||
{ trackingNumber: '123456-EL-DRW-0001', title: 'Electrical Arrangement',
|
||||
revision: 'B', status: 'IFR', extension: '.dwg',
|
||||
originalFilename: '123456-EL-DRW-0001_B (IFR) - Electrical Arrangement',
|
||||
name: '123456-EL-DRW-0001_B (IFR) - Electrical Arrangement.dwg',
|
||||
path: 'test-project/123456-EL-DRW-0001_B (IFR) - Electrical Arrangement.dwg',
|
||||
size: 120000, isDirectory: false },
|
||||
];
|
||||
const tree = [{ path: 'test-project', name: 'test-project', files, children: [] }];
|
||||
window.app.modules.store.setFolderTree(tree);
|
||||
window.app.modules.store.setSelectedFolders(['test-project']);
|
||||
|
||||
const displayFiles = window.app.modules.store.getDisplayFiles();
|
||||
const computed = displayFiles.map(f =>
|
||||
window.app.modules.spreadsheet.computeNewFilename(f, 0) || ''
|
||||
);
|
||||
return { count: displayFiles.length, computed };
|
||||
});
|
||||
|
||||
// Both files should be in the store
|
||||
expect(result.count).toBe(2);
|
||||
|
||||
// Each computed filename should contain its tracking number
|
||||
// (sort order may vary, so check both filenames contain a tracking number)
|
||||
const allFilenames = result.computed.join('\n');
|
||||
expect(allFilenames).toContain('123456-EL-SPC-2623');
|
||||
expect(allFilenames).toContain('123456-EL-DRW-0001');
|
||||
});
|
||||
|
||||
test('extension is stored without leading dot, joined back with one', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.app !== 'undefined' && window.app.modules?.utils, { timeout: 15000 });
|
||||
|
||||
const probe = await page.evaluate(() => {
|
||||
const split = window.zddc.splitExtension('test.pdf');
|
||||
const joined = window.zddc.joinExtension('test', 'pdf');
|
||||
const file = {
|
||||
originalFilename: '123456-EL-SPC-2623_A (IFC) - Spec',
|
||||
extension: split.extension,
|
||||
trackingNumber: '123456-EL-SPC-2623',
|
||||
revision: 'A',
|
||||
status: 'IFC',
|
||||
title: 'Spec',
|
||||
};
|
||||
return {
|
||||
splitExt: split.extension,
|
||||
joined: joined,
|
||||
computedFilename: window.app.modules.utils.computeNewFilename(file),
|
||||
};
|
||||
});
|
||||
|
||||
expect(probe.splitExt).toBe('pdf'); // no leading dot stored
|
||||
expect(probe.joined).toBe('test.pdf'); // joinExtension adds the dot
|
||||
expect(probe.computedFilename).toBe('123456-EL-SPC-2623_A (IFC) - Spec.pdf');
|
||||
});
|
||||
|
||||
test('zddc.filter is exposed as a sub-namespace of zddc', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.zddc?.filter === 'object', { timeout: 15000 });
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const ast = window.zddc.filter.parse('foo');
|
||||
return {
|
||||
hasParse: typeof window.zddc.filter.parse === 'function',
|
||||
hasMatches: typeof window.zddc.filter.matches === 'function',
|
||||
noLegacyGlobal: typeof window.ZDDCFilter === 'undefined',
|
||||
matches: window.zddc.filter.matches('foobar', ast),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.hasParse).toBe(true);
|
||||
expect(result.hasMatches).toBe(true);
|
||||
expect(result.noLegacyGlobal).toBe(true);
|
||||
expect(result.matches).toBe(true);
|
||||
});
|
||||
});
|
||||
196
tests/fixtures/mock-fs-api.js
vendored
Normal file
196
tests/fixtures/mock-fs-api.js
vendored
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* Reusable File System Access API mock for Playwright tests.
|
||||
*
|
||||
* Usage in a Playwright test:
|
||||
*
|
||||
* import { MOCK_FS_INIT_SCRIPT, buildMockDirectory } from './fixtures/mock-fs-api.js';
|
||||
*
|
||||
* test.beforeEach(async ({ page }) => {
|
||||
* await page.addInitScript(MOCK_FS_INIT_SCRIPT);
|
||||
* });
|
||||
*
|
||||
* test('loads files', async ({ page }) => {
|
||||
* const files = [
|
||||
* { name: '123456-EL-SPC-2623_A (IFR) - Spec.pdf', content: 'pdf-bytes', size: 45000 },
|
||||
* ];
|
||||
* await page.evaluate((files) => {
|
||||
* window.__setMockDirectory('test-project', files);
|
||||
* }, files);
|
||||
* await page.goto(`file://${htmlPath}`);
|
||||
* });
|
||||
*/
|
||||
|
||||
/**
|
||||
* Init script string to inject via page.addInitScript().
|
||||
* Defines MockFileHandle, MockDirectoryHandle, and overrides the
|
||||
* showDirectoryPicker / showOpenFilePicker / showSaveFilePicker APIs.
|
||||
*/
|
||||
export const MOCK_FS_INIT_SCRIPT = `
|
||||
class MockFileHandle {
|
||||
constructor(name, content, size) {
|
||||
this.kind = 'file';
|
||||
this.name = name;
|
||||
this._content = content || '';
|
||||
this._size = size || (content ? content.length : 0);
|
||||
}
|
||||
|
||||
async getFile() {
|
||||
const blob = new File([this._content], this.name, {
|
||||
lastModified: Date.now(),
|
||||
type: this._guessMimeType(),
|
||||
});
|
||||
if (this._size && this._size !== blob.size) {
|
||||
Object.defineProperty(blob, 'size', { value: this._size, writable: false });
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
async createWritable() {
|
||||
const name = this.name;
|
||||
const chunks = [];
|
||||
return {
|
||||
write(data) { chunks.push(data); },
|
||||
close() {
|
||||
window.__writtenFiles = window.__writtenFiles || {};
|
||||
window.__writtenFiles[name] = chunks;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
_guessMimeType() {
|
||||
const ext = this.name.split('.').pop().toLowerCase();
|
||||
const types = {
|
||||
pdf: 'application/pdf',
|
||||
html: 'text/html',
|
||||
json: 'application/json',
|
||||
txt: 'text/plain',
|
||||
dwg: 'application/acad',
|
||||
md: 'text/markdown',
|
||||
};
|
||||
return types[ext] || 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
class MockDirectoryHandle {
|
||||
constructor(name, entries) {
|
||||
this.kind = 'directory';
|
||||
this.name = name;
|
||||
this._entries = entries || [];
|
||||
}
|
||||
|
||||
async *values() {
|
||||
for (const entry of this._entries) {
|
||||
yield entry;
|
||||
}
|
||||
}
|
||||
|
||||
async *entries() {
|
||||
for (const entry of this._entries) {
|
||||
yield [entry.name, entry];
|
||||
}
|
||||
}
|
||||
|
||||
async *keys() {
|
||||
for (const entry of this._entries) {
|
||||
yield entry.name;
|
||||
}
|
||||
}
|
||||
|
||||
async getFileHandle(name, opts) {
|
||||
const found = this._entries.find(e => e.kind === 'file' && e.name === name);
|
||||
if (found) return found;
|
||||
if (opts && opts.create) {
|
||||
const handle = new MockFileHandle(name, '');
|
||||
this._entries.push(handle);
|
||||
return handle;
|
||||
}
|
||||
throw new DOMException('A requested file or directory could not be found.', 'NotFoundError');
|
||||
}
|
||||
|
||||
async getDirectoryHandle(name, opts) {
|
||||
const found = this._entries.find(e => e.kind === 'directory' && e.name === name);
|
||||
if (found) return found;
|
||||
if (opts && opts.create) {
|
||||
const handle = new MockDirectoryHandle(name, []);
|
||||
this._entries.push(handle);
|
||||
return handle;
|
||||
}
|
||||
throw new DOMException('A requested file or directory could not be found.', 'NotFoundError');
|
||||
}
|
||||
|
||||
async queryPermission() { return 'granted'; }
|
||||
async requestPermission() { return 'granted'; }
|
||||
async resolve(child) { return child ? [child.name] : null; }
|
||||
}
|
||||
|
||||
// Expose constructors globally for per-test customization
|
||||
window.__MockFileHandle = MockFileHandle;
|
||||
window.__MockDirectoryHandle = MockDirectoryHandle;
|
||||
window.__writtenFiles = {};
|
||||
|
||||
/**
|
||||
* Helper to build a mock directory from a flat file list.
|
||||
* @param {string} dirName - Root directory name
|
||||
* @param {Array<{name: string, content?: string, size?: number}>} files - Flat file list
|
||||
*/
|
||||
window.__setMockDirectory = function(dirName, files) {
|
||||
const entries = (files || []).map(f =>
|
||||
new MockFileHandle(f.name, f.content || '', f.size || 0)
|
||||
);
|
||||
window.__mockRootDirectory = new MockDirectoryHandle(dirName, entries);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to build nested mock directories.
|
||||
* @param {string} dirName - Root directory name
|
||||
* @param {Object} tree - Nested structure: { 'subdir': { 'file.txt': 'content' }, 'root.pdf': 'content' }
|
||||
*/
|
||||
window.__setMockDirectoryTree = function(dirName, tree) {
|
||||
function buildEntries(obj) {
|
||||
const entries = [];
|
||||
for (const [name, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'object' && value !== null && !ArrayBuffer.isView(value)) {
|
||||
entries.push(new MockDirectoryHandle(name, buildEntries(value)));
|
||||
} else {
|
||||
entries.push(new MockFileHandle(name, String(value), String(value).length));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
window.__mockRootDirectory = new MockDirectoryHandle(dirName, buildEntries(tree));
|
||||
};
|
||||
|
||||
// Override File System Access API pickers
|
||||
window.showDirectoryPicker = async function(opts) {
|
||||
if (!window.__mockRootDirectory) {
|
||||
throw new DOMException('The user aborted a request.', 'AbortError');
|
||||
}
|
||||
return window.__mockRootDirectory;
|
||||
};
|
||||
|
||||
window.showOpenFilePicker = async function(opts) {
|
||||
if (!window.__mockOpenFiles || window.__mockOpenFiles.length === 0) {
|
||||
throw new DOMException('The user aborted a request.', 'AbortError');
|
||||
}
|
||||
return window.__mockOpenFiles;
|
||||
};
|
||||
|
||||
window.showSaveFilePicker = async function(opts) {
|
||||
const name = (opts && opts.suggestedName) || 'untitled';
|
||||
const handle = new MockFileHandle(name, '');
|
||||
window.__lastSaveHandle = handle;
|
||||
return handle;
|
||||
};
|
||||
`;
|
||||
|
||||
/**
|
||||
* Helper to build a mock directory structure for page.evaluate().
|
||||
* Call this in test code (Node-side), then pass to page.evaluate().
|
||||
*
|
||||
* @param {string} name - Directory name
|
||||
* @param {Array<{name: string, content?: string, size?: number}>} files
|
||||
* @returns {Object} Serializable directory description
|
||||
*/
|
||||
export function buildMockDirectory(name, files) {
|
||||
return { name, files };
|
||||
}
|
||||
163
tests/fixtures/transmittal-data.js
vendored
Normal file
163
tests/fixtures/transmittal-data.js
vendored
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Transmittal JSON test fixtures.
|
||||
* Matches transmittal/transmittal.schema.json (JSON Schema draft 2020-12).
|
||||
*/
|
||||
|
||||
/** Minimal valid transmittal (required fields only) */
|
||||
export const MINIMAL_TRANSMITTAL = {
|
||||
payload: {
|
||||
version: 1,
|
||||
type: 'Transmittal',
|
||||
date: '2025-10-31',
|
||||
trackingNumber: '123456-EL-TRN-0001',
|
||||
files: [
|
||||
{
|
||||
filename: '123456-EL-SPC-2623_A (IFR) - Specification For Switchgear.pdf',
|
||||
sha256: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
|
||||
},
|
||||
],
|
||||
},
|
||||
envelope: {
|
||||
version: 1,
|
||||
digestAlgorithm: 'SHA-256',
|
||||
digest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
digestedAt: '2025-10-31T12:00:00.000Z',
|
||||
signatureAlgorithm: 'ECDSA-P256-SHA256',
|
||||
},
|
||||
};
|
||||
|
||||
/** Full transmittal with all optional fields populated */
|
||||
export const FULL_TRANSMITTAL = {
|
||||
payload: {
|
||||
version: 1,
|
||||
type: 'Transmittal',
|
||||
title: 'Electrical Design Package',
|
||||
client: 'ACME Corporation',
|
||||
project: 'New Substation',
|
||||
projectNumber: '123456',
|
||||
date: '2025-10-31',
|
||||
trackingNumber: '123456-EL-TRN-0043',
|
||||
from: 'Engineering Team',
|
||||
to: 'Construction Team',
|
||||
purpose: 'IFC',
|
||||
responseDue: '2025-11-15',
|
||||
subject: 'Electrical Design Issued For Construction',
|
||||
remarks: '## Notes\n\nPlease review the attached specifications and drawings.\n\n- Item 1\n- Item 2',
|
||||
files: [
|
||||
{
|
||||
trackingNumber: '123456-EL-SPC-2623',
|
||||
revision: 'A',
|
||||
status: 'IFC',
|
||||
title: 'Specification For Switchgear',
|
||||
path: '',
|
||||
filename: '123456-EL-SPC-2623_A (IFC) - Specification For Switchgear.pdf',
|
||||
sha256: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
|
||||
fileSize: 45000,
|
||||
},
|
||||
{
|
||||
trackingNumber: '123456-EL-ARR-0003',
|
||||
revision: '0',
|
||||
status: 'IFC',
|
||||
title: 'Electrical Room Equipment Arrangement',
|
||||
path: 'drawings',
|
||||
filename: '123456-EL-ARR-0003_0 (IFC) - Electrical Room Equipment Arrangement.pdf',
|
||||
sha256: 'b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3',
|
||||
fileSize: 500000,
|
||||
},
|
||||
{
|
||||
trackingNumber: '123456-EL-DWG-0010',
|
||||
revision: 'A',
|
||||
status: 'IFC',
|
||||
title: 'Single Line Diagram',
|
||||
path: 'drawings',
|
||||
filename: '123456-EL-DWG-0010_A (IFC) - Single Line Diagram.pdf',
|
||||
sha256: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
|
||||
fileSize: 250000,
|
||||
},
|
||||
],
|
||||
},
|
||||
envelope: {
|
||||
version: 1,
|
||||
digestAlgorithm: 'SHA-256',
|
||||
digest: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
|
||||
digestedAt: '2025-10-31T14:30:00.000Z',
|
||||
signatureAlgorithm: 'ECDSA-P256-SHA256',
|
||||
signatures: [],
|
||||
},
|
||||
presentation: {
|
||||
theme: 'default',
|
||||
customCss: '',
|
||||
},
|
||||
};
|
||||
|
||||
/** Submittal type (shows responseDue field) */
|
||||
export const SUBMITTAL = {
|
||||
payload: {
|
||||
version: 1,
|
||||
type: 'Submittal',
|
||||
date: '2025-10-31',
|
||||
trackingNumber: '123456-EL-SUB-0001',
|
||||
from: 'Contractor',
|
||||
to: 'Engineer',
|
||||
purpose: 'IFA',
|
||||
responseDue: '2025-11-14',
|
||||
subject: 'Switchgear Submittal',
|
||||
files: [
|
||||
{
|
||||
trackingNumber: '123456-EL-SPC-2623',
|
||||
revision: 'A',
|
||||
status: 'IFA',
|
||||
title: 'Specification For Switchgear',
|
||||
filename: '123456-EL-SPC-2623_A (IFA) - Specification For Switchgear.pdf',
|
||||
sha256: 'f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1',
|
||||
fileSize: 45000,
|
||||
},
|
||||
],
|
||||
},
|
||||
envelope: {
|
||||
version: 1,
|
||||
digestAlgorithm: 'SHA-256',
|
||||
digest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
digestedAt: '2025-10-31T10:00:00.000Z',
|
||||
signatureAlgorithm: 'ECDSA-P256-SHA256',
|
||||
},
|
||||
};
|
||||
|
||||
/** Invalid transmittal payloads for negative testing */
|
||||
export const INVALID_TRANSMITTALS = [
|
||||
{
|
||||
description: 'Missing required payload.version',
|
||||
data: {
|
||||
payload: { type: 'Transmittal', date: '2025-10-31', trackingNumber: '123456-EL-TRN-0001', files: [] },
|
||||
envelope: { version: 1, digestAlgorithm: 'SHA-256', digest: 'a'.repeat(64), digestedAt: '2025-10-31T12:00:00.000Z', signatureAlgorithm: 'ECDSA-P256-SHA256' },
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'Invalid date format',
|
||||
data: {
|
||||
payload: { version: 1, type: 'Transmittal', date: '31-10-2025', trackingNumber: '123456-EL-TRN-0001', files: [] },
|
||||
envelope: { version: 1, digestAlgorithm: 'SHA-256', digest: 'a'.repeat(64), digestedAt: '2025-10-31T12:00:00.000Z', signatureAlgorithm: 'ECDSA-P256-SHA256' },
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'Tracking number with underscore',
|
||||
data: {
|
||||
payload: { version: 1, type: 'Transmittal', date: '2025-10-31', trackingNumber: '123456_EL_TRN_0001', files: [] },
|
||||
envelope: { version: 1, digestAlgorithm: 'SHA-256', digest: 'a'.repeat(64), digestedAt: '2025-10-31T12:00:00.000Z', signatureAlgorithm: 'ECDSA-P256-SHA256' },
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'Invalid SHA-256 hash (wrong length)',
|
||||
data: {
|
||||
payload: { version: 1, type: 'Transmittal', date: '2025-10-31', trackingNumber: '123456-EL-TRN-0001', files: [{ filename: 'test.pdf', sha256: 'abc123' }] },
|
||||
envelope: { version: 1, digestAlgorithm: 'SHA-256', digest: 'a'.repeat(64), digestedAt: '2025-10-31T12:00:00.000Z', signatureAlgorithm: 'ECDSA-P256-SHA256' },
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'File with additional unexpected property',
|
||||
data: {
|
||||
payload: { version: 1, type: 'Transmittal', date: '2025-10-31', trackingNumber: '123456-EL-TRN-0001', files: [{ filename: 'test.pdf', sha256: 'a'.repeat(64), unexpected: true }] },
|
||||
envelope: { version: 1, digestAlgorithm: 'SHA-256', digest: 'a'.repeat(64), digestedAt: '2025-10-31T12:00:00.000Z', signatureAlgorithm: 'ECDSA-P256-SHA256' },
|
||||
},
|
||||
},
|
||||
];
|
||||
191
tests/fixtures/zddc-filenames.js
vendored
Normal file
191
tests/fixtures/zddc-filenames.js
vendored
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* ZDDC filename test fixtures.
|
||||
*
|
||||
* Covers the full naming convention from README.md:
|
||||
* trackingNumber_revision (status) - title.extension
|
||||
*
|
||||
* These fixtures are shared across tool tests to ensure consistent
|
||||
* parsing behavior (archive, transmittal, classifier all parse filenames).
|
||||
*/
|
||||
|
||||
/** Valid ZDDC filenames with expected parsed components */
|
||||
export const VALID_FILES = [
|
||||
// --- Standard letter revisions ---
|
||||
{
|
||||
filename: '123456-EL-SPC-2623_A (IFR) - Specification For Switchgear.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-SPC-2623', revision: 'A', status: 'IFR', title: 'Specification For Switchgear', extension: 'pdf' },
|
||||
size: 45000,
|
||||
},
|
||||
{
|
||||
filename: '123456-EL-SPC-2623_B (IFR) - Specification For Switchgear.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-SPC-2623', revision: 'B', status: 'IFR', title: 'Specification For Switchgear', extension: 'pdf' },
|
||||
size: 46000,
|
||||
},
|
||||
|
||||
// --- Standard number revisions (issued for construction) ---
|
||||
{
|
||||
filename: '123456-EL-SPC-2623_0 (IFC) - Specification For Switchgear.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-SPC-2623', revision: '0', status: 'IFC', title: 'Specification For Switchgear', extension: 'pdf' },
|
||||
size: 47000,
|
||||
},
|
||||
{
|
||||
filename: '123456-EL-SPC-2623_1 (IFC) - Specification For Switchgear.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-SPC-2623', revision: '1', status: 'IFC', title: 'Specification For Switchgear', extension: 'pdf' },
|
||||
size: 48000,
|
||||
},
|
||||
|
||||
// --- Revision modifiers ---
|
||||
{
|
||||
filename: '123456-EL-SPC-2623_A+C1 (RSB) - Specification For Switchgear.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-SPC-2623', revision: 'A+C1', status: 'RSB', title: 'Specification For Switchgear', extension: 'pdf' },
|
||||
size: 12000,
|
||||
description: 'Comments to Rev A',
|
||||
},
|
||||
{
|
||||
filename: '123456-EL-SPC-2623_A+C2 (RSA) - Specification For Switchgear.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-SPC-2623', revision: 'A+C2', status: 'RSA', title: 'Specification For Switchgear', extension: 'pdf' },
|
||||
size: 13000,
|
||||
description: 'Second set of comments to Rev A',
|
||||
},
|
||||
{
|
||||
filename: '123456-EL-SPC-2623_A+B1 (IFI) - Specification For Switchgear.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-SPC-2623', revision: 'A+B1', status: 'IFI', title: 'Specification For Switchgear', extension: 'pdf' },
|
||||
size: 8000,
|
||||
description: 'Backup material for Rev A',
|
||||
},
|
||||
{
|
||||
filename: '123456-EL-SPC-2623_A+N1 (IFI) - Specification For Switchgear.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-SPC-2623', revision: 'A+N1', status: 'IFI', title: 'Specification For Switchgear', extension: 'pdf' },
|
||||
size: 5000,
|
||||
description: 'Notes for Rev A',
|
||||
},
|
||||
{
|
||||
filename: '123456-EL-SPC-2623_A+Q1 (RSA) - Specification For Switchgear.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-SPC-2623', revision: 'A+Q1', status: 'RSA', title: 'Specification For Switchgear', extension: 'pdf' },
|
||||
size: 3000,
|
||||
description: 'Quality check record for Rev A',
|
||||
},
|
||||
|
||||
// --- Draft indicators ---
|
||||
{
|
||||
filename: '123456-EL-SPC-2623_~B (IFR) - Specification For Switchgear.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-SPC-2623', revision: '~B', status: 'IFR', title: 'Specification For Switchgear', extension: 'pdf' },
|
||||
size: 44000,
|
||||
description: 'Working draft of Rev B',
|
||||
},
|
||||
{
|
||||
filename: '123456-EL-SPC-2623_A+~C1 (---) - Specification For Switchgear.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-SPC-2623', revision: 'A+~C1', status: '---', title: 'Specification For Switchgear', extension: 'pdf' },
|
||||
size: 11000,
|
||||
description: 'Working draft of comments to Rev A, status unknown',
|
||||
},
|
||||
|
||||
// --- Multiple disciplines ---
|
||||
{
|
||||
filename: '123456-EM-MDL-0001_A (IFR) - Master Deliverables List.pdf',
|
||||
parsed: { trackingNumber: '123456-EM-MDL-0001', revision: 'A', status: 'IFR', title: 'Master Deliverables List', extension: 'pdf' },
|
||||
size: 120000,
|
||||
},
|
||||
{
|
||||
filename: '123456-EL-ARR-0003_A (IFR) - Electrical Room Equipment Arrangement.dwg',
|
||||
parsed: { trackingNumber: '123456-EL-ARR-0003', revision: 'A', status: 'IFR', title: 'Electrical Room Equipment Arrangement', extension: 'dwg' },
|
||||
size: 500000,
|
||||
},
|
||||
{
|
||||
filename: '123456-ME-RFI-0024_A (IFR) - Mechanical Room Size RFI.pdf',
|
||||
parsed: { trackingNumber: '123456-ME-RFI-0024', revision: 'A', status: 'IFR', title: 'Mechanical Room Size RFI', extension: 'pdf' },
|
||||
size: 25000,
|
||||
},
|
||||
|
||||
// --- Issued status codes (no DFT — drafts use ~rev prefix) ---
|
||||
{ filename: '123456-EL-DWG-0002_A (IFA) - Approval Drawing.pdf', parsed: { trackingNumber: '123456-EL-DWG-0002', revision: 'A', status: 'IFA', title: 'Approval Drawing', extension: 'pdf' }, size: 10000 },
|
||||
{ filename: '123456-EL-DWG-0003_A (IFB) - Bid Drawing.pdf', parsed: { trackingNumber: '123456-EL-DWG-0003', revision: 'A', status: 'IFB', title: 'Bid Drawing', extension: 'pdf' }, size: 10000 },
|
||||
{ filename: '123456-EL-DWG-0004_A (IFC) - Construction Drawing.pdf', parsed: { trackingNumber: '123456-EL-DWG-0004', revision: 'A', status: 'IFC', title: 'Construction Drawing', extension: 'pdf' }, size: 10000 },
|
||||
{ filename: '123456-EL-DWG-0005_A (IFD) - Design Drawing.pdf', parsed: { trackingNumber: '123456-EL-DWG-0005', revision: 'A', status: 'IFD', title: 'Design Drawing', extension: 'pdf' }, size: 10000 },
|
||||
{ filename: '123456-EL-DWG-0006_A (IFI) - Information Drawing.pdf', parsed: { trackingNumber: '123456-EL-DWG-0006', revision: 'A', status: 'IFI', title: 'Information Drawing', extension: 'pdf' }, size: 10000 },
|
||||
{ filename: '123456-EL-DWG-0007_A (IFP) - Purchase Drawing.pdf', parsed: { trackingNumber: '123456-EL-DWG-0007', revision: 'A', status: 'IFP', title: 'Purchase Drawing', extension: 'pdf' }, size: 10000 },
|
||||
{ filename: '123456-EL-DWG-0008_A (IFR) - Review Drawing.pdf', parsed: { trackingNumber: '123456-EL-DWG-0008', revision: 'A', status: 'IFR', title: 'Review Drawing', extension: 'pdf' }, size: 10000 },
|
||||
{ filename: '123456-EL-DWG-0009_A (IFU) - Use Drawing.pdf', parsed: { trackingNumber: '123456-EL-DWG-0009', revision: 'A', status: 'IFU', title: 'Use Drawing', extension: 'pdf' }, size: 10000 },
|
||||
{ filename: '123456-EL-DWG-0010_A (REC) - Record Drawing.pdf', parsed: { trackingNumber: '123456-EL-DWG-0010', revision: 'A', status: 'REC', title: 'Record Drawing', extension: 'pdf' }, size: 10000 },
|
||||
|
||||
// --- Review status codes (used with modifiers) ---
|
||||
{ filename: '123456-EL-DWG-0001_A+C1 (RSA) - No Comments.pdf', parsed: { trackingNumber: '123456-EL-DWG-0001', revision: 'A+C1', status: 'RSA', title: 'No Comments', extension: 'pdf' }, size: 5000 },
|
||||
{ filename: '123456-EL-DWG-0001_A+C1 (RSB) - Incorporate and Resubmit.pdf', parsed: { trackingNumber: '123456-EL-DWG-0001', revision: 'A+C1', status: 'RSB', title: 'Incorporate and Resubmit', extension: 'pdf' }, size: 5000 },
|
||||
{ filename: '123456-EL-DWG-0001_A+C1 (RSC) - Revise and Resubmit.pdf', parsed: { trackingNumber: '123456-EL-DWG-0001', revision: 'A+C1', status: 'RSC', title: 'Revise and Resubmit', extension: 'pdf' }, size: 5000 },
|
||||
{ filename: '123456-EL-DWG-0001_A+C1 (RSD) - Rejected.pdf', parsed: { trackingNumber: '123456-EL-DWG-0001', revision: 'A+C1', status: 'RSD', title: 'Rejected', extension: 'pdf' }, size: 5000 },
|
||||
{ filename: '123456-EL-DWG-0001_A+B1 (RSI) - Supplemental Info.pdf', parsed: { trackingNumber: '123456-EL-DWG-0001', revision: 'A+B1', status: 'RSI', title: 'Supplemental Info', extension: 'pdf' }, size: 5000 },
|
||||
|
||||
// --- Record/construction lifecycle ---
|
||||
{
|
||||
filename: '123456-EL-ARR-0003_0 (IFC) - Electrical Room Equipment Arrangement.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-ARR-0003', revision: '0', status: 'IFC', title: 'Electrical Room Equipment Arrangement', extension: 'pdf' },
|
||||
size: 50000,
|
||||
description: 'First construction issue',
|
||||
},
|
||||
{
|
||||
filename: '123456-EL-ARR-0003_1 (IFC) - Electrical Room Equipment Arrangement.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-ARR-0003', revision: '1', status: 'IFC', title: 'Electrical Room Equipment Arrangement', extension: 'pdf' },
|
||||
size: 51000,
|
||||
description: 'Construction revision',
|
||||
},
|
||||
{
|
||||
filename: '123456-EL-ARR-0003_2 (REC) - Electrical Room Equipment Arrangement.pdf',
|
||||
parsed: { trackingNumber: '123456-EL-ARR-0003', revision: '2', status: 'REC', title: 'Electrical Room Equipment Arrangement', extension: 'pdf' },
|
||||
size: 52000,
|
||||
description: 'For record',
|
||||
},
|
||||
];
|
||||
|
||||
/** Invalid filenames for negative testing */
|
||||
export const INVALID_FILES = [
|
||||
{ filename: 'random-document.pdf', reason: 'No ZDDC naming convention' },
|
||||
{ filename: 'has spaces in tracking_A (IFR) - Title.pdf', reason: 'Spaces in tracking number' },
|
||||
{ filename: '123456_EL_SPC_2623_A (IFR) - Title.pdf', reason: 'Underscores in tracking number' },
|
||||
{ filename: '123456-EL-SPC-2623_A (IFR).pdf', reason: 'Missing title separator' },
|
||||
{ filename: '123456-EL-SPC-2623_A - Title.pdf', reason: 'Missing status' },
|
||||
{ filename: '123456-EL-SPC-2623 (IFR) - Title.pdf', reason: 'Missing revision' },
|
||||
{ filename: '123456-EL-SPC-2623_ (IFR) - Title.pdf', reason: 'Empty revision' },
|
||||
{ filename: '123456-EL-SPC-2623_A () - Title.pdf', reason: 'Empty status' },
|
||||
{ filename: '123456-EL-SPC-2623_A (IFR) - .pdf', reason: 'Empty title' },
|
||||
];
|
||||
|
||||
/** Transmittal folder names */
|
||||
export const VALID_FOLDERS = [
|
||||
{
|
||||
foldername: '2025-10-31_123456-EM-SUB-0001 (IFR) - General Arrangement for Review',
|
||||
parsed: { date: '2025-10-31', trackingNumber: '123456-EM-SUB-0001', status: 'IFR', title: 'General Arrangement for Review' },
|
||||
},
|
||||
{
|
||||
foldername: '2025-10-31_123456-EL-TRN-0043 (IFC) - Electrical Design Issued For Construction',
|
||||
parsed: { date: '2025-10-31', trackingNumber: '123456-EL-TRN-0043', status: 'IFC', title: 'Electrical Design Issued For Construction' },
|
||||
},
|
||||
{
|
||||
foldername: '2025-10-31_123456-ME-RFI-0024 (IFR) - Mechanical Room Size RFI',
|
||||
parsed: { date: '2025-10-31', trackingNumber: '123456-ME-RFI-0024', status: 'IFR', title: 'Mechanical Room Size RFI' },
|
||||
},
|
||||
{
|
||||
foldername: '2025-11-12_123456-ME-RFI-0024 (IFU) - Mechanical Room Size RFI',
|
||||
parsed: { date: '2025-11-12', trackingNumber: '123456-ME-RFI-0024', status: 'IFU', title: 'Mechanical Room Size RFI' },
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Expected revision sort order (letters before numbers, drafts before finals).
|
||||
* This documents the canonical behavior -- tests should verify each tool's
|
||||
* compareRevisions() matches this order.
|
||||
*/
|
||||
export const REVISION_SORT_ORDER = [
|
||||
'~A', // draft of A
|
||||
'A', // final A
|
||||
'A+B1', // backup material for A
|
||||
'A+C1', // comments to A
|
||||
'A+~C2', // draft of second comments to A
|
||||
'A+C2', // second comments to A
|
||||
'A+N1', // notes for A
|
||||
'A+Q1', // quality check for A
|
||||
'~B', // draft of B
|
||||
'B', // final B
|
||||
'0', // first construction issue
|
||||
'1', // construction revision
|
||||
'2', // for record
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue