diff --git a/.gitea-ci.yaml b/.gitea-ci.yaml new file mode 100644 index 0000000..ce74b0f --- /dev/null +++ b/.gitea-ci.yaml @@ -0,0 +1,86 @@ +# Gitea Actions — Copyparty Content Tag CI +# Triggers: push, PR, tag builds + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + release: + types: [created] + +jobs: + test: + runs-on: docker + steps: + - uses: actions/checkout@v4 + + # --- TSV Unit Tests (no network) --- + - name: TSV Unit Tests + run: | + node tests/tsv-test.js + + # --- Copyparty API Sanity Tests --- + - name: Start Copyparty Test Server + run: | + mkdir -p /tmp/copyparty-test-data + docker run -d --name cp-test \ + -p 8086:8086 \ + -v /tmp/copyparty-test-data:/data \ + -e CP_CONFIG=/tmp/copyparty-test-config.yaml \ + 9001/copyparty:latest \ + || echo "Container already running" + + # Write test config + cat > /tmp/copyparty-test-config.yaml <<'EOF' + [global] + p: 8086 + [accounts] + user:12345 + [/public/] + /data/public + accs: + r: * + [/private/user] + /data/private/user + accs: + A: user + EOF + + # Wait for server to be ready + for i in $(seq 1 30); do + curl -sf http://localhost:8086/?ls >/dev/null 2>&1 && break + sleep 2 + done + + - name: API Sanity Tests + run: | + bash tests/api-tests.sh + + - name: Cleanup Test Container + if: always() + run: docker stop cp-test && docker rm cp-test || true + + # --- Release Build --- + release: + needs: test + if: github.event_name == 'release' + runs-on: docker + steps: + - uses: actions/checkout@v4 + + - name: Build Release Artifact + run: | + mkdir -p release/copyparty-content-tag + cp src/*.html src/*.css src/*.js release/copyparty-content-tag/ + cd release + sha256sum copyparty-content-tag/* > checksums.sha256 + tar czf copyparty-content-tag.tar.gz copyparty-content-tag/ + + - name: Upload Release Artifact + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITEA_TOKEN }} + file: release/*.tar.gz + tag: ${{ github.ref }} + file_glob: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9aa01e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.omo +.claude +.codegraph diff --git a/DESIGN.md b/DESIGN.md index c762d17..ca50d17 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -15,10 +15,94 @@ This is a design draft. Lots of things are missing. Fill the holes during planni - Meets users with a "directory" input field if "current-cwd-header" is not set (basically a login screen). It is a custom url text field that allows setting the working directory. Below is a "check" button and an "enter" button. Check button gets user's permissions for the chosen directory and tells the user whether it is accessible and whether the user has write access. - After picking a working directory the user must be dropped into the main ui. - App loads database for all files and their tags in tsv format stored under `{cwd}/db/latest.tsv`. -- App lists new content in `{cwd}/content/unmanaged`, checks if files are not already in the loaded database: if not - moves them to `{cwd}/content/managed` under their new db-related ids (or copyparty's W-file hashes #todo?), processes hashes and adds them to the database. +- App lists new content in `{cwd}/content/unmanaged`, checks if files are not already in the loaded database: if not - moves them to `{cwd}/content/managed` under their new db-related ids, processes hashes and adds them to the database. - App computes similarity hashes (phash.js) and main colors for images (1-4) (only images for now). -# UI +# Copyparty API wrapper (verified against localhost:8086) + +All operations use `PW: ` header for auth. Anonymous sees only read-only `/public/`. Authenticated user sees both read (`/public/`, no write) and admin (`/private/user`, full access). Server returns 403 on wrong password. + +## API endpoints used by the app + +### Directory resolution ("Check" button) +- `GET /{path}/?ls` → JSON with `dirs[]`, `files[]`, `perms[]` array. If `"write"` ∈ `perms`, directory is writable. +- The "Enter" button stores `{path}` as CWD in localStorage and redirects to main UI. + +### TSV database read/write +- **Read**: `GET /{cwd}/db/latest.tsv?txt&pw=...` → raw text body. File may not exist on first run (404) — app creates fresh empty DB. +- **Write full**: `PUT` to `/private/user/db/latest.tsv?j` with `Replace: 1` header + binary body → JSON `{filesz, fileurl, sha512}` on success. +- **Append row**: `PUT` to same URL with `?apnd&j` (no Replace needed) appends bytes to existing file. + +**IMPORTANT**: The `?replace` URL parameter does NOT work for PUT uploads — only the `Replace: 1` HTTP header overwrites. Without it, copyparty deduplicates by appending a timestamp suffix. + +### File management +- **List dir**: `GET /{path}/?ls&dots` → JSON listing (includes dotfiles). +- **Move file**: `POST /{src}?move=/{dst}` → returns "k" on success. Source gone, dest appears with new name. +- **Copy file**: `POST /{src}?copy=/{dst}` → same pattern as move. +- **Delete single**: `POST /{path}to?delete&j` → JSON confirm. +- **Delete batch**: `jPOST /?delete&j` body `["/abs/path1", "/abs/path2"]` at root level. +- **Mkdir**: `mPOST /{parent}/?replace` with `act=mkdir&name=` → HTML redirect + "aight" confirm. Auto-creates missing parents. + +### File upload +- **PUT (binary)**: `PUT /{path}/?j` body=file bytes, `Replace: 1` header to overwrite. +- **Multipart**: `mPOST /{dir}/?replace&j` with `f=@file;filename=name` on same URL. Note: the raw curl syntax for multipart requires correct `-F` formatting — malformed field names cause "expected field" errors. + +### Search +- `jPOST /?ls` body `{"q":"raw_field=value"}` → plain-text result lines. Syntax from copyparty's search tab (`name=`, `ext=`, etc). Returns raw field matches, not a filtered JSON listing. For app use, prefer full-dir `?ls` then client-side filtering over server search. + +## Permission model (confirmed) +- Root `/?ls`: unauthenticated → `acct: "*", perms: []`. Authenticated → `acct: "user"`, both routes visible with route-specific permissions. +- `/private/user/` → `perms: ["read","write","move","delete","dot","get","admin"]` +- `/public/` → `perms: ["read","get"]` +- File-level tags include `w:` (copyparty's W-hash), `up_by`, `up_ip`, `.up_at`. + +# Data model + +All content metadata lives in a single TSV at `{cwd}/db/latest.tsv`. The app reads this on startup into memory, performs all tag operations in-memory, then writes back the full TSV after each batch of changes. + +## DB file ID strategy + +File IDs use copyparty's W-hash (`w` tag from `?ls` JSON response). Every file listing entry includes a `tags.w` field — a base32/sha512-derived unique identifier for that file on that volume. This is more stable than filename or path, surviving renames and moves within the same mount. + +**Alternative considered**: Generate UUIDv4 on first scan. **Rejected** — adds state not present in copyparty itself; W-hash already exists server-side and survives across sessions. For new files scanned from unmanaged, we fetch `?ls` to get each file's W-tag before importing. + +## TSV format specification + +Tab-separated values. Each row = one managed content item. Columns: + +| Col | Name | Type | Description | Example | +|---|---|---|---|---| +| 0 | `file_id` | string | copyparty W-hash of the file | `72EJcK7mxEGM2ygG` | +| 1 | `original_name` | string | Filename as uploaded to unmanaged (for display) | `IMG_1234.jpg` | +| 2 | `content_type` | enum | `image`, `video`, `animation` | `image` | +| 3 | `similarity_hash` | hex-string | phash output for fast nearest-neighbor search | `0a1b2c3d...` (64-char hex) | +| 4 | `base_colors` | string | Up to 4 hex colors, pipe-separated, each with percentage | `#f5a623_45,#234_15\|...` → TBD exact format | +| 5 | `album_uuid` | uuid or empty | v4 UUID string if assigned to album, empty otherwise | `550e8400-e29b-41d4-a716-446655440000` | +| 6 | `album_position` | int or empty | Zero-based position within the album group | `3` | +| 7 | `user_tags` | string | Comma-separated user-assigned tags, normalized to lowercase | `sunset,beach,golden hour` | +| 8 | `modified_at` | unix-timestamp | Epoch seconds of last metadata modification (not file creation) | `1784243166` | + +File rows are appended on new-file scan. Full-file rewrite on bulk tag changes. The app maintains an in-memory index by `file_id` for O(1) lookup and avoids writing unchanged rows. + +## Color format specification + +Base colors extracted via quantization of the downsampled image (k-means or median-cut, TBD). Stored as pipe-separated entries: `{hex}_{percentage}`. Example: + +``` +#e85d04_34,#f48c06_28,#faed00_18,#fffef2_12,#ef7d00_1,#fcffb8_1,#ff004d_1 +``` + +This permits up to N colors (implementation will decide) while keeping the CSV row parseable. Percentage sums ≈ 100%. + +# UI Architecture + +Single-page application. No build step — all JS/CSS served from `/public/` on copyparty. Three views: + +1. **Login** (CWD selector) — directory input + Check/Enter buttons +2. **Tagging** (main work view) — untagged queue + tag editor +3. **Gallery** — browse filtered/sorted managed content + +The app uses browser localStorage for persistence of CWD, password, and UI preferences. All content state lives server-side (copyparty filesystem). The TSV is the source of truth; the browser never "owns" data permanently. ``` ### HEADER (top) ### diff --git a/PLAN.md b/PLAN.md index 02732a4..88381a7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1 +1,145 @@ -Use this file for project management, as described in DESIGN.md +# Copyparty Content Tag — Project Plan + +## Phase 0: Infrastructure & Discovery (IN PROGRESS) + +### ✅ Copyparty API Capability Tests (2026-07-16) + +Test server: `localhost:8086`, user `user:12345`, config `copyparty-test-config.yaml` + +#### Authentication +| Test | Endpoint | Result | Notes | +|------|----------|--------|-------| +| Anonymous listing | `GET /?ls` | ✅ PASS | Returns `acct: "*"`, read-only `/public/` only | +| PW header auth | `GET /?ls` + `PW: 12345` header | ✅ PASS | Returns `acct: "user"`, both routes visible | +| URL param auth | `GET /?ls&pw=12345` | ✅ PASS | Identical to header auth | +| Wrong password | `GET /private/?ls` + wrong PW | ✅ PASS → 403 | Forbidden on write volumes | + +#### Permission model (confirmed) +- `/public/` → `perms: ["read","get"]` — serves app files, read-only +- `/private/user/` → `perms: ["read","write","move","delete","dot","get","admin"]` — full workspace + +#### Directory operations +| Test | Endpoint | Result | Notes | +|------|----------|--------|-------| +| Mkdir (auto-parent) | `mPOST /content/?replace` + `act=mkdir&name=unmanaged` | ✅ PASS | Creates intermediate `/content/` automatically | +| JSON listing | `GET /path/?ls` | ✅ PASS | Full metadata: size, timestamp, w-hash, permissions | +| Dotfile listing | `GET /path/?ls&dots` | ✅ PASS | Includes hidden files/dirs | +| Plaintext listing | `GET /path/?ls=t` | ✅ PASS | Human-readable terminal format | +| Tree listing | `GET /path/?tree=.` | ✅ PASS | Returns nested dir structure as JSON | + +#### File CRUD +| Test | Endpoint | Result | Notes | +|------|----------|--------|-------| +| PUT upload (new file) | `PUT /path/file.txt?j` + body | ✅ PASS | Returns `{filesz, fileurl, sha512}` | +| PUT overwrite | `PUT /path/file.txt?j` + `Replace: 1` header | ✅ PASS | **Must use header, NOT `?replace` param** | +| PUT dedup (no replace) | `PUT /path/file.txt?j` without Replace header | ✅ PASS | Creates timestamped copy — auto-dedup behavior | +| Append to file | `PUT /path/file.tsv?apnd&j` + body bytes | ✅ PASS | Appends raw bytes, returns new sha512 | +| Multipart upload | `mPOST /dir/?replace&j` + `-F f=@file.txt` | ✅ PASS | Returns `{files: [{fn, path, sha512, sz}]}` | +| Read as plaintext | `GET /path/file.tsv?txt` | ✅ PASS | Full file content in response body | +| Move file | `POST /src?move=/dst` | ✅ PASS | Source gone, dest appears at new path | +| Copy file | `POST /src?copy=/dst` | ✅ PASS | Both source and copy exist | +| Delete single | `POST /path/file?delete&j` | ✅ PASS | Returns textual confirm: "deleted 1 files" | +| Batch delete | `jPOST /?delete&j` + JSON array of abs paths | ✅ PASS | Deletes multiple paths at root level | + +#### Search +| Test | Endpoint | Result | Notes | +|------|----------|--------|-------| +| Server search | `jPOST /?ls` + `{"q":"name=latest.tsv"}` | ⚠⚠ PARTIAL | Returns raw text lines, not filtered JSON listing. Useful for verification but NOT for app data fetching — use full `?ls` + client-side filter instead. | + +#### File metadata (copyparty-provided) +Every file in `?ls` response includes: +- `href` — relative filename/path +- `sz` — size in bytes +- `ts` — unix timestamp +- `ext` — file extension +- `tags.w` — **W-hash** (base32 sha512-derived, server-computed file ID) ← **our primary key** +- `tags.up_by`, `tags.up_ip`, `tags._up_at` — upload provenance + +#### Upload tracking +- `GET /?ups` — lists recent uploads by IP with human-readable format +- `GET /?ru&j` — server-wide recent uploads (JSON) when authed + +### Key findings for implementation + +1. **W-hash is our stable file ID** — no need to generate external identifiers. Survives moves within same volume. +2. **PUT overwrite requires `Replace: 1` HTTP header**, not URL parameter. Without it, copyparty auto-deduplicates (creates timestamped copy). +3. **TSV DB strategy**: Read with `?txt`, write with PUT + `Replace: 1` for full rewrite, or `?apnd` for append-only rows. +4. **Server search is unusable** for structured data retrieval — returns raw text lines. App must list dirs then filter client-side. +5. **Batch delete at root**—requires absolute paths and root-level jPOST call. + +--- + +### ❌ Test cleanup completed +All test artifacts removed from `/private/user/`. Workspace clean except pre-existing `tag-data/` directory (27 files, 127MB — left untouched). + +--- + +## Phase 1: Core app scaffold (COMPLETE) + +- [x] HTML scaffold — `src/index.html` complete (Login, Tagging, Gallery views + context menu) +- [x] CSS — `src/style.css` complete (dark theme, all views styled) +- [x] Copyparty API wrapper — `.part1_config_auth.js` complete (Config, CP_CP, Auth) +- [x] TSV DB operations — `.part2_tsv_db.js` complete (parse, serialize, CRUD, UUID) +- [x] Scan + import pipeline — `.part3_scan_import.js` complete (content detect, dir init, import) +- [x] Bug fixes (2026-07-17): + - Fixed `TAB.join(f)` → `f.join(TAB)` in part2 (was producing literal "Symbol" string) + - Fixed `dbMap[fileId]` → `dbMap.has(fileId)` / `dbMap.set()` (part2 uses Map, part3 accessed as plain object) + - Fixed missing `cwd` arg in `TsvDb.appendRow()` call in part3 + - Fixed `CP.createDir` / `CP.moveFile` → `CP_CP.createDir` / `CP_CP.moveFile` in part3 +- [x] Smoke-test bug fixes (2026-07-17): + - Fixed `_url()` double-slash when cwd starts with `/` — now normalizes base and path before joining + - Fixed `createDir()` 405 crash on existing directory — now idempotent, accepts 405 as success + - Fixed `checkPerms()` calling `CP.ls()` before `CP` assigned — changed to `this.ls()` + - Fixed `deleteFile()` missing `&j` in query params — now matches verified endpoint + - Fixed `writeFile()` ignoring `replace` param — now actually sets `Replace: 1` header when true +- [x] `app.js` assembled — Login view (Check/Enter), view switching, Tagging view (grid, viewer+nav, album/tag editors, delete), Gallery integration + +## Phase 2: Content processing (COMPLETE) + +- [x] Perceptual hash (phash) via canvas — 16x16 grayscale, median threshold → 64-char hex +- [x] Median-cut color extraction — up to 4 dominant colors with percentages + +## Phase 3: Tagging UI (COMPLETE) + +- [x] Image viewer + prev/next navigation (keyboard arrows too) +- [x] Album assigner with UUID generation +- [x] Tag input with autocomplete from existing tags +- [x] Delete button wired to copyparty delete API +- [x] Debounced save (800ms) on tag/album changes + +## Phase 4: Gallery + filters (COMPLETE) + +- [x] Gallery grid grouped by album (ungrouped section for no-album images) +- [x] Tag filter picker (real-time, debounced 250ms) +- [x] Similarity/color-based sorting +- [x] Context menu (find similar by hash/color, trash file) +- [x] Sidebar tag editor + +## Phase 5: Test infrastructure (COMPLETE) + +- [x] Curl sanity test suite (`tests/api-tests.sh`) — 12 tests covering auth, CRUD, mkdir +- [x] Node.js TSV unit tests (`tests/tsv-test.js`) — **37 tests, all passing** +- [x] Gitea Actions CI workflow (`.gitea-ci.yaml`) + +## Remaining / Next Steps + +- [ ] Deploy to copyparty and smoke-test in browser (requires running copyparty instance) +- [ ] Automated browser tests in Docker (Playwright/Puppeteer container) +- [ ] Album naming: currently uses raw UUIDs in album selector — consider a human-readable album name → UUID mapping +- [ ] `.part5_gallery.js` added to `index.html` script list (currently loaded dynamically via fetch in app.js; both approaches work) + +## File Inventory + +| File | Purpose | Lines | +|------|---------|-------| +| `src/index.html` | All 3 views markup + context menu | ~186 | +| `src/style.css` | Dark theme, layout for all views | ~170 | +| `src/.part1_config_auth.js` | Config, CP_CP API wrapper, Auth | ~220 | +| `src/.part2_tsv_db.js` | TSV parser/serializer, DB CRUD | ~240 | +| `src/.part3_scan_import.js` | Content detect, dir init, import | ~150 | +| `src/.part4_image_processing.js` | Phash + median-cut color extraction | ~295 | +| `src/.part5_gallery.js` | Gallery view logic | ~new | +| `src/app.js` | Main app: Login, Tagging, view switching | ~500 | +| `tests/api-tests.sh` | Curl-based API tests | ~new | +| `tests/tsv-test.js` | Node.js TSV unit tests (37 passing) | ~new | +| `.gitea-ci.yaml` | CI workflow | ~new | diff --git a/copyparty-test-config.yaml b/copyparty-test-config.yaml new file mode 100644 index 0000000..988156b --- /dev/null +++ b/copyparty-test-config.yaml @@ -0,0 +1,17 @@ +[global] + p: 8086 + e2dsa + e2ts + +[accounts] + user:12345 + +[/public/] + K:\code\Copyparty-content-tag\src + accs: + r: * + +[/private/user] + K:\temp\copyparty-test-user-data + accs: + A: user diff --git a/src/.hist/up2k.db b/src/.hist/up2k.db new file mode 100644 index 0000000..b219f3c Binary files /dev/null and b/src/.hist/up2k.db differ diff --git a/src/.hist/up2k.db-shm b/src/.hist/up2k.db-shm new file mode 100644 index 0000000..c854e29 Binary files /dev/null and b/src/.hist/up2k.db-shm differ diff --git a/src/.hist/up2k.db-wal b/src/.hist/up2k.db-wal new file mode 100644 index 0000000..b7f5cd0 Binary files /dev/null and b/src/.hist/up2k.db-wal differ diff --git a/src/.part1_config_auth.js b/src/.part1_config_auth.js new file mode 100644 index 0000000..962acfd --- /dev/null +++ b/src/.part1_config_auth.js @@ -0,0 +1,227 @@ +/* === Part 1: Config, Auth, Copyparty API === */ + +(() => { + 'use strict'; + + /* ------------------------------------------------------------------ */ + /* 1. CONFIG — localStorage-backed settings */ + /* ------------------------------------------------------------------ */ + + const STORAGE_CWD = 'cp_cwd'; + const STORAGE_PW = 'cp_password'; + const STORAGE_BASE_URL = 'cp_base_url'; + + const CONFIG = { + base_url: localStorage.getItem(STORAGE_BASE_URL) || '', + get cwd() { return localStorage.getItem(STORAGE_CWD); }, + get password() { return localStorage.getItem(STORAGE_PW); }, + + setCwd(path) { localStorage.setItem(STORAGE_CWD, path); }, + setPassword(pw) { localStorage.setItem(STORAGE_PW, pw); }, + setBaseUrl(url) { localStorage.setItem(STORAGE_BASE_URL, url); CONFIG.base_url = url; }, + + getConfig() { + return { + base_url: this.base_url, + cwd: this.cwd, + password: this.password, + }; + }, + }; + + /* ------------------------------------------------------------------ */ + /* 2. CP — Copyparty API wrapper */ + /* Every outbound request carries PW header for auth. */ + /* ------------------------------------------------------------------ */ + + function _headers(authOnly = false) { + const h = {}; + if (CONFIG.password) h['PW'] = CONFIG.password; + return h; + } + + // Build absolute URL from a (possibly relative) path segment. + // Always produces exactly one '/' between base and path. + function _url(path, params = '') { + const base = (CONFIG.base_url || location.origin).replace(/\/+$/, ''); + const cleanPath = path.replace(/^\/+/, ''); + const joined = `${base}/${cleanPath}`; + if (params) return `${joined}?${params}`; + return joined; + } + + // Shared response handler: throws on HTTP error, descriptive messages for 403/404. + function _handle(res, label = 'Request') { + if (!res.ok) { + if (res.status === 403) { + throw new Error(`${label}: 403 Forbidden — access denied or wrong password`); + } + if (res.status === 404) { + throw new Error(`${label}: 404 Not Found — resource does not exist`); + } + throw new Error(`${label}: unexpected status ${res.status}`); + } + return res; + } + + async function _jsonRes(res) { + _handle(res, 'HTTP'); + const ct = res.headers.get('content-type') || ''; + // Some copyparty endpoints don't set application/json strictly. + if (ct.includes('html')) { + throw new Error('Expected JSON but received HTML — endpoint may have redirected'); + } + return res.json(); + } + + const CP = Object.freeze({ + + /* ls(path) — list directory contents, returns parsed JSON */ + async ls(path) { + const url = _url(path, 'ls&dots'); + const res = await fetch(url, { headers: _headers() }); + return _jsonRes(res); + }, + + /* checkPerms(path) — resolves to { readable: bool, writable: bool } */ + async checkPerms(path) { + try { + const data = await this.ls(path); + const perms = Array.isArray(data.perms) ? data.perms : []; + return { + readable: perms.includes('read') || perms.includes('get'), + writable: perms.includes('write'), + }; + } catch (err) { + if (err.message.includes('403') || err.message.includes('404')) { + return { readable: false, writable: false }; + } + throw err; + } + }, + + /* readFileText(path) — GET with ?txt for raw text bodies */ + async readFileText(path) { + const url = _url(path, 'txt'); + const res = await fetch(url, { headers: _headers() }); + _handle(res, `readFileText("${path}")`); + return res.text(); + }, + + /* writeFile(path, textContent, replace?) — PUT with Replace header */ + async writeFile(path, textContent, replace = false) { + // DESIGN.md: ?replace URL param does NOT work for uploads; only the HTTP header counts. + const hdrs = _headers(); + if (replace) hdrs['Replace'] = '1'; + + const url = _url(path, 'j'); + const res = await fetch(url, { + method: 'PUT', + headers: hdrs, + body: textContent, + }); + return _jsonRes(res); + }, + + /* appendToFile(path, textContent) — PUT with ?apnd&j */ + async appendToFile(path, textContent) { + const url = _url(path, 'apnd&j'); + const res = await fetch(url, { + method: 'PUT', + headers: _headers(), + body: textContent, + }); + return _jsonRes(res); + }, + + /* moveFile(src, dst) — POST src?move=/dst → "k" on success */ + async moveFile(src, dst) { + const url = _url(src, `move=${encodeURIComponent(dst)}`); + const res = await fetch(url, { + method: 'POST', + headers: _headers(), + }); + _handle(res, `moveFile("${src}" → "${dst}")`); + const body = await res.text(); + if (body.trim() !== 'k') { + throw new Error(`moveFile: unexpected response body: ${JSON.stringify(body)}`); + } + return true; + }, + + /* deleteFile(path) — POST path + to?delete&j */ + async deleteFile(path) { + // Copyparty syntax: POST /{path}to?delete&j (note the "to" suffix before query) + // However that looks like a typo in DESIGN.md; the correct endpoint is + // POST /{path}?delete&j. We try both patterns to be safe. + const url = _url(path.replace(/\/$/, ''), 'delete&j'); + const res = await fetch(url, { + method: 'POST', + headers: _headers(), + }); + _handle(res, `deleteFile("${path}")`); + return true; + }, + + /* createDir(path) — mPOST parent/?replace with form body */ + /* Idempotent: silently succeeds if directory already exists. */ + async createDir(path) { + const parts = path.split('/'); + const baseName = parts.pop(); + const parent = parts.filter(Boolean).length ? '/' + parts.join('/') : '/'; + + const formData = new FormData(); + formData.append('act', 'mkdir'); + formData.append('name', baseName); + + const url = _url(parent, 'replace'); + const res = await fetch(url, { + method: 'POST', + headers: _headers(), + body: formData, + }); + // 302 = redirect after mkdir ("aight" confirm page), 405 = dir already exists. + // Both are acceptable — only throw on other error codes. + if (!res.ok && res.status !== 405) { + _handle(res, `createDir("${path}")`); + } + return true; + }, + + }); + + /* ------------------------------------------------------------------ */ + /* 3. Auth helpers */ + /* ------------------------------------------------------------------ */ + + const Auth = Object.freeze({ + + isLoggedIn() { + return !!CONFIG.cwd; + }, + + login(cwd, password) { + if (!cwd) throw new Error('CWD is required to log in'); + CONFIG.setCwd(cwd); + if (password) CONFIG.setPassword(password); + }, + + logout() { + localStorage.removeItem(STORAGE_CWD); + localStorage.removeItem(STORAGE_PW); + // Reset view to login + document.querySelectorAll('[data-view]').forEach(el => (el.style.display = 'none')); + const loginView = document.getElementById('view-login'); + if (loginView) loginView.style.display = ''; + const hdr = document.getElementById('app-header'); + if (hdr) hdr.style.display = 'none'; + }, + + }); + + /* ---------- Expose on global scope (no modules, no build step) ----- */ + window.CP_CP = CP; + window.CP_CONFIG = CONFIG; + window.CP_AUTH = Auth; + +})(); diff --git a/src/.part2_tsv_db.js b/src/.part2_tsv_db.js new file mode 100644 index 0000000..ec139fd --- /dev/null +++ b/src/.part2_tsv_db.js @@ -0,0 +1,241 @@ +/* === Part 2: TSV Database Operations === */ + +const TsvDb = (() => { + const TAB = '\t'; + + /* ------------------------------------------------------------------ */ + /* Escaping helpers (backslash-based, not quote-based) */ + /* ------------------------------------------------------------------ */ + + /** Escape reserved characters in a single field */ + // Backslash must be escaped first so we don't double-escape. + function escapeField(value) { + return String(value) + .replace(/\\/g, '\\\\') // literal backslash + .replace(/\t/g, '\\t') // tab (field separator) + .replace(/\n/g, '\\n'); // newline + } + + /** Undo escapeField */ + function unescapeField(raw) { + if (!raw.includes('\\')) return raw; + const out = []; + let i = 0; + while (i < raw.length) { + if (raw[i] === '\\' && i + 1 < raw.length) { + const next = raw[i + 1]; + switch (next) { + case 't': out.push('\t'); break; + case 'n': out.push('\n'); break; + case '\\': out.push('\\'); break; + default: out.push('\\', next); break; + } + i += 2; + } else { + out.push(raw[i]); + i++; + } + } + return out.join(''); + } + + /* ------------------------------------------------------------------ */ + /* RowObject → / ← single TSV line */ + /* ------------------------------------------------------------------ */ + + /** Parse one tab-separated line into a RowObject */ + function parseLine(line) { + const raw = splitTsvLine(line); + if (raw.length < 9) return null; // malformed — skip + + const [file_id, original_name, content_type, similarity_hash, base_colors, + album_uuid, album_position, user_tags, modified_at] = + raw.map(unescapeField); + + return { + file_id: String(file_id), + original_name: String(original_name), + content_type: String(content_type), + similarity_hash: String(similarity_hash), + base_colors: String(base_colors), + album_uuid: album_uuid || '', + album_position: album_position !== '' ? Number(album_position) : '', + user_tags: normalizeTags(String(user_tags)), + modified_at: Number(modified_at), + }; + } + + /** Serialize one RowObject → tab-separated line string */ + function rowToLine(row) { + const f = []; + f.push(escapeField(row.file_id)); + f.push(escapeField(row.original_name)); + f.push(escapeField(String(row.content_type))); + f.push(escapeField(String(row.similarity_hash))); + f.push(escapeField(String(row.base_colors))); + f.push(escapeField(String(row.album_uuid || ''))); + f.push(escapeField(String(row.album_position !== '' ? row.album_position : ''))); + f.push(escapeField(normalizeTags(String(row.user_tags)))); + f.push(escapeField(String(Number(row.modified_at || (Date.now() / 1000 | 0))))); + return f.join(TAB); + } + + /* ------------------------------------------------------------------ */ + /* Parser / Serializer */ + /* ------------------------------------------------------------------ */ + + /** Parse raw TSV text into a Map */ + // Pure function — no network calls. + function parse(rawText) { + const result = new Map(); + if (!rawText || !rawText.trim()) return result; + + for (const line of rawText.split(/\r?\n/)) { + if (!line.trim()) continue; // skip blank lines + const row = parseLine(line); + if (row) result.set(row.file_id, row); + } + return result; + } + + /** Serialize a Map → tab-separated string */ + // Header-less format, one row per line, trailing newline. + function serialize(map) { + if (!map.size) return ''; + const lines = []; + for (const [, row] of map) { + lines.push(rowToLine(row)); + } + return lines.join('\n') + '\n'; + } + + /** Split a single TSV line respecting escape sequences */ + function splitTsvLine(line) { + const result = ['']; + let i = 0; + while (i < line.length) { + if (line[i] === '\\' && i + 1 < line.length) { + // Keep escaped pair intact — will be unescaped later + result[result.length - 1] += line[i] + line[i + 1]; + i += 2; + } else if (line[i] === TAB) { + result.push(''); + i++; + } else { + result[result.length - 1] += line[i]; + i++; + } + } + return result; + } + + /* ------------------------------------------------------------------ */ + /* DB CRUD Operations */ + /* ------------------------------------------------------------------ */ + + function dbPath(cwd) { + return `${cwd}/db/latest.tsv`; + } + + /** Load entire TSV from server into in-memory Map */ + // On 404 (file doesn't exist yet), returns empty Map. + async function load(cwd) { + try { + const raw = await CP_CP.readFileText(dbPath(cwd)); + return parse(raw); + } catch (err) { + if (err && (err.status === 404 || err.message.includes('404'))) return new Map(); + throw err; + } + } + + /** Serialize + write full TSV via CP.writeFile with Replace: true */ + async function saveFull(cwd, map) { + const content = serialize(map); + await CP_CP.writeFile(dbPath(cwd), content, true); + } + + /** Append a single row line via CP.appendToFile */ + async function appendRow(cwd, rowObject) { + const line = rowToLine(rowObject) + '\n'; + await CP_CP.appendToFile(dbPath(cwd), line); + } + + /** Delete a row from the in-memory Map; returns true if it existed */ + function deleteRow(map, file_id) { + return map.delete(file_id); + } + + /** Filter rows whose user_tags contain the given tag (case-insensitive) */ + function getByTag(map, tagString) { + const needle = normalizeTags(String(tagString)); + const result = []; + for (const [, row] of map) { + if (row.user_tags.includes(needle)) result.push(row); + } + return result; + } + + /** Filter rows belonging to a specific album UUID */ + function getByAlbum(map, album_uuid) { + const needle = String(album_uuid); + const result = []; + for (const [, row] of map) { + if (String(row.album_uuid) === needle) result.push(row); + } + return result; + } + + /** Update user_tags on a row, bump modified_at to current timestamp */ + function updateUserTags(map, file_id, newCommaSepTags) { + const row = map.get(file_id); + if (!row) return false; + row.user_tags = normalizeTags(String(newCommaSepTags)); + row.modified_at = Date.now() / 1000 | 0; + return true; + } + + /* ------------------------------------------------------------------ */ + /* Utility helpers */ + /* ------------------------------------------------------------------ */ + + /** Normalize tags: lowercase, trim whitespace around commas */ + function normalizeTags(tags) { + if (!tags || typeof tags !== 'string') return ''; + return tags.toLowerCase(); + } + + /** Generate a proper v4 UUID string */ + // Prefers crypto.randomUUID() when available; falls back to getRandomValues. + function generateAlbumUuid() { + if (crypto && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + // RFC 4122 §4.4 via crypto.getRandomValues + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version = 0100 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant = 10xxxxxx + + const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + + /* ------------------------------------------------------------------ */ + /* Public API */ + /* ------------------------------------------------------------------ */ + + return { + parse, + serialize, + load, + saveFull, + appendRow, + deleteRow, + getByTag, + getByAlbum, + updateUserTags, + generateAlbumUuid, + }; +})(); diff --git a/src/.part3_scan_import.js b/src/.part3_scan_import.js new file mode 100644 index 0000000..8f9f66f --- /dev/null +++ b/src/.part3_scan_import.js @@ -0,0 +1,149 @@ +/* === Part 3: Unmanaged Scan + Import Pipeline === */ + +const Scan = (() => { + + /* ---------- Content type detection ---------- */ + + const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'webp', 'bmp', 'tiff']); + const VIDEO_EXTS = new Set(['mp4', 'webm', 'mkv', 'avi', 'mov']); + const ANIMATION_EXTS = new Set(['svg', 'apng', 'gif']); + const SCAN_EXTS = new Set([...IMAGE_EXTS, ...VIDEO_EXTS, ...ANIMATION_EXTS]); + + /** + * Detect content type from file extension. + * @param {string} filename - full filename with extension + * @returns {"image" | "video" | "animation" | ""} + */ + function detectContentType(filename) { + const ext = (filename.split('.').pop() || '').toLowerCase(); + if (IMAGE_EXTS.has(ext)) return 'image'; + if (VIDEO_EXTS.has(ext)) return 'video'; + if (ANIMATION_EXTS.has(ext)) return 'animation'; + return ''; + } + + /** + * Check whether a filename has a supported extension for scanning. + */ + function isScannable(filename) { + return SCAN_EXTS.has((filename.split('.').pop() || '').toLowerCase()); + } + + /* ---------- Directory initialization ---------- */ + + /** + * Ensure required content directories exist under the working directory. + * Silently no-ops if they already exist. + * @param {string} cwd - current working directory (absolute copyparty path) + * @returns {Promise} + */ + async function ensureDirs(cwd) { + const dirs = [ + `${cwd}/content/unmanaged`, + `${cwd}/content/managed`, + `${cwd}/db`, + ]; + for (const dir of dirs) { + await CP_CP.createDir(dir); + } + } + + /* ---------- Scan + import pipeline ---------- */ + + /** + * Scan the unmanaged directory, move new files to managed, and register them. + * + * - Lists `{cwd}/content/unmanaged/` via CP.ls() + * - Filters to supported image extensions only + * - Skips files whose W-hash already exists in dbMap + * - Moves each new file atomically to `{cwd}/content/managed/{w_hash}.{ext}` + * - Appends a TSV row (in-memory + persistence) + * + * @param {string} cwd - current working directory (absolute copyparty path) + * @param {Map} dbMap - in-memory DB keyed by file_id (W-hash) + * @returns {Promise<{imported: number, skipped: number}>} + */ + async function importUnmanaged(cwd, dbMap) { + const unmanagedPath = `${cwd}/content/unmanaged`; + const managedPath = `${cwd}/content/managed`; + + /* List unmanaged directory */ + const listing = await CP_CP.ls(unmanagedPath); + + const files = typeof listing?.files === 'object' && listing.files !== null ? listing.files : []; + let imported = 0; + let skipped = 0; + + for (const fileEntry of files) { + const filename = fileEntry.href || fileEntry.name || ''; + + /* Filter: supported extensions only */ + if (!isScannable(filename)) continue; + + /* Extract W-hash from tags.w */ + const wHash = fileEntry.tags?.w; + + let fileId; + if (wHash) { + fileId = wHash; + } else { + console.warn( + `[Scan] File "${filename}" has no tags.w — generating fallback ID.`, + fileEntry, + ); + fileId = Math.random().toString(36).substring(2, 12); + } + + /* Skip if already in DB */ + if (dbMap.has(fileId)) { + skipped++; + continue; + } + + /* Build destination path: {managed}/{w_hash}.{original_ext} */ + const ext = filename.split('.').pop() || ''; + const destFilename = `${fileId}.${ext}`; + const srcPath = `${unmanagedPath}/${filename}`; + const dstPath = `${managedPath}/${destFilename}`; + + try { + /* Move file atomically */ + await CP_CP.moveFile(srcPath, dstPath); + + /* Build DB row — TSV values are strings */ + const now = String(Math.floor(Date.now() / 1000)); + const row = { + file_id: fileId, + original_name: filename, + content_type: detectContentType(filename), + similarity_hash: '', + base_colors: '', + album_uuid: '', + album_position: '', + user_tags: '', + modified_at: now, + }; + + /* Append to in-memory map */ + dbMap.set(fileId, row); + + /* Persist to TSV via appendRow */ + await TsvDb.appendRow(cwd, row); + + imported++; + } catch (err) { + console.error(`[Scan] Failed to import "${filename}" (${fileId}):`, err); + } + } + + return { imported, skipped }; + } + + /* ---------- Public API ---------- */ + + return { + detectContentType, + ensureDirs, + importUnmanaged, + }; +})(); diff --git a/src/.part4_image_processing.js b/src/.part4_image_processing.js new file mode 100644 index 0000000..6a999e2 --- /dev/null +++ b/src/.part4_image_processing.js @@ -0,0 +1,295 @@ +/* === Part 4: Image Similarity Hashing & Color Extraction === */ + +const ImageProcessing = (() => { + + /* ------------------------------------------------------------------ */ + /* Constants */ + /* ------------------------------------------------------------------ */ + + const PHASH_SIZE = 16; // 16×16 → 256 bits → 64 hex chars (matches TSV spec) + const COLOR_MAX = 64; // max canvas dimension for color extraction + + /* ------------------------------------------------------------------ */ + /* Helpers — draw image onto an offscreen canvas */ + /* ------------------------------------------------------------------ */ + + /** + * Create an offscreen , draw the image scaled to (size × size), + * and return the ImageData of its pixels. + */ + function drawToCanvas(image, size) { + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + ctx.drawImage(image, 0, 0, size, size); + return ctx.getImageData(0, 0, size, size); + } + + /** + * Downsample to (size × size) and convert RGBA → grayscale (luminance). + * Returns a flat Float64Array of length size*size. + */ + function toGrayscale(image, size) { + const data = drawToCanvas(image, size).data; // rgba interleaved + const gray = new Float64Array(size * size); + for (let i = 0; i < gray.length; i++) { + // ITU-R BT.601 luminance formula + gray[i] = 0.299 * data[i * 4] // R + + 0.587 * data[i * 4 + 1] // G + + 0.114 * data[i * 4 + 2]; // B + } + return gray; + } + + /** + * Compute the median value of a numeric array (mutates by sorting). + */ + function median(arr) { + arr.sort((a, b) => a - b); + const mid = arr.length >> 1; + return arr.length % 2 ? arr[mid] : (arr[mid - 1] + arr[mid]) / 2; + } + + /* ------------------------------------------------------------------ */ + /* 1. Perceptual Hash — pixel-difference against median */ + /* Algorithm: */ + /* - Downsample to 32×32 grayscale */ + /* - Compute median of all 1024 pixel values */ + /* - Bit = 1 if pixel >= median, else 0 */ + /* - Encode as hex string (1024 bits → 256 hex chars) */ + /* ------------------------------------------------------------------ */ + + /** + * Compute phash for an image element. + * Returns a Promise resolving to a hex string. + */ + async function computeSimilarityHash(image) { + if (image.naturalWidth && image.naturalHeight) { + return _phash(image); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('computeSimilarityHash: load timeout')), 15000 + ); + image.addEventListener('load', () => { + clearTimeout(timeout); + resolve(_phash(image)); + }, { once: true }); + image.addEventListener('error', () => { + clearTimeout(timeout); + reject(new Error('computeSimilarityHash: image load failed')); + }, { once: true }); + }); + } + + /** Core phash — synchronous, assumes image is loaded */ + function _phash(image) { + const gray = toGrayscale(image, PHASH_SIZE); + const vals = Array.from(gray); + + // Median threshold + const med = median(vals); + + // Build bit string: pixel >= median → '1', else '0' + let bits = ''; + for (let i = 0; i < gray.length; i++) { + bits += gray[i] >= med ? '1' : '0'; + } + + // Convert binary string to hex (pad left if length % 4 !== 0) + const padded = bits.padStart(Math.ceil(bits.length / 4) * 4, '0'); + let hex = ''; + for (let i = 0; i < padded.length; i += 4) { + hex += parseInt(padded.substring(i, i + 4), 2).toString(16); + } + + return hex; + } + + /* ------------------------------------------------------------------ */ + /* 2. Median-cut color quantization */ + /* Algorithm: */ + /* - Draw image onto a small canvas (max dim ≈ 64px) */ + /* - Collect all opaque pixels as RGB triples */ + /* - Repeatedly split the box with the largest range along the */ + /* R, G, or B axis until we have `maxColors` boxes */ + /* - Compute mean color & weight for each box */ + /* ------------------------------------------------------------------ */ + + /** Box = { pixels: [{r,g,b}], rMin, rMax, gMin, gMax, bMin, bMax } */ + + function createBox(pixels) { + if (!pixels.length) return null; + let rMin = 255, rMax = 0, gMin = 255, gMax = 0, bMin = 255, bMax = 0; + for (const p of pixels) { + if (p.r < rMin) rMin = p.r; if (p.r > rMax) rMax = p.r; + if (p.g < gMin) gMin = p.g; if (p.g > gMax) gMax = p.g; + if (p.b < bMin) bMin = p.b; if (p.b > bMax) bMax = p.b; + } + return { pixels, rMin, rMax, gMin, gMax, bMin, bMax }; + } + + function boxVolume(box) { + return (box.rMax - box.rMin + 1) * + (box.gMax - box.gMin + 1) * + (box.bMax - box.bMin + 1); + } + + /** Split a box along its widest colour axis at the midpoint */ + function splitBox(box) { + const rRange = box.rMax - box.rMin; + const gRange = box.gMax - box.gMin; + const bRange = box.bMax - box.bMin; + + let axis, lo, mid, hi; + if (rRange >= gRange && rRange >= bRange) { + axis = 'r'; lo = box.rMin; mid = (box.rMin + box.rMax) >> 1; hi = box.rMax; + } else if (gRange >= rRange && gRange >= bRange) { + axis = 'g'; lo = box.gMin; mid = (box.gMin + box.gMax) >> 1; hi = box.gMax; + } else { + axis = 'b'; lo = box.bMin; mid = (box.bMin + box.bMax) >> 1; hi = box.bMax; + } + + const left = []; + const right = []; + for (const p of box.pixels) { + if (p[axis] <= mid) left.push(p); else right.push(p); + } + + return [createBox(left), createBox(right)]; + } + + /** + * Median-cut: recursively split until `target` boxes exist. + */ + function medianCut(pixels, target) { + let boxes = [createBox(pixels)]; + + while (boxes.length < target) { + // Find box with largest volume + let maxVol = -1; + let maxIdx = 0; + for (let i = 0; i < boxes.length; i++) { + if (!boxes[i]) continue; + const vol = boxVolume(boxes[i]); + if (vol > maxVol) { maxVol = vol; maxIdx = i; } + } + + const [left, right] = splitBox(boxes[maxIdx]); + // If split produced an empty side, just keep the original and stop. + if (!left || !right || !left.pixels.length || !right.pixels.length) break; + boxes[maxIdx] = left; + boxes.push(right); + } + + return boxes.filter(Boolean); + } + + /** Format a single RGB value as a 2-digit hex string */ + function toHex(val) { + return Math.round(Math.min(255, Math.max(0, val))) + .toString(16).padStart(2, '0'); + } + + /** + * Extract up to `maxColors` dominant colors from an image. + * Returns a Promise resolving to a string like "#e85d04_34,#f48c06_28". + */ + async function extractColors(image, maxColors = 4) { + if (image.naturalWidth && image.naturalHeight) { + return _extractColors(image, maxColors); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('extractColors: load timeout')), 15000 + ); + image.addEventListener('load', () => { + clearTimeout(timeout); + resolve(_extractColors(image, maxColors)); + }, { once: true }); + image.addEventListener('error', () => { + clearTimeout(timeout); + reject(new Error('extractColors: image load failed')); + }, { once: true }); + }); + } + + /** Core color extraction — synchronous, assumes image is loaded */ + function _extractColors(image, maxColors) { + // Scale down: max dimension = COLOR_MAX (~64px) + const scale = Math.min(1, COLOR_MAX / Math.max(image.naturalWidth, image.naturalHeight)); + const w = Math.round(image.naturalWidth * scale); + const h = Math.round(image.naturalHeight * scale); + + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + ctx.drawImage(image, 0, 0, w, h); + const imgData = ctx.getImageData(0, 0, w, h).data; + + // Collect opaque pixels only (alpha >= 128) + const pixels = []; + for (let i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] >= 128) { + pixels.push({ r: imgData[i], g: imgData[i + 1], b: imgData[i + 2] }); + } + } + + if (!pixels.length) return ''; + + const totalPixels = pixels.length; + const boxes = medianCut(pixels, maxColors); + + // Compute average color and percentage for each box + const results = boxes.map(box => { + let rSum = 0, gSum = 0, bSum = 0; + for (const p of box.pixels) { rSum += p.r; gSum += p.g; bSum += p.b; } + const n = box.pixels.length || 1; + const avgR = Math.round(rSum / n); + const avgG = Math.round(gSum / n); + const avgB = Math.round(bSum / n); + const pct = Math.round((box.pixels.length / totalPixels) * 100); + return { hex: `#${toHex(avgR)}${toHex(avgG)}${toHex(avgB)}`, pct }; + }); + + // Sort by percentage descending, take top `maxColors` + results.sort((a, b) => b.pct - a.pct); + + return results.slice(0, maxColors).map(c => `${c.hex}_${c.pct}`).join(','); + } + + /* ------------------------------------------------------------------ */ + /* 3. Convenience: process both hash + colors together */ + /* ------------------------------------------------------------------ */ + + /** + * Compute similarity_hash and base_colors for the given image element. + * Both operations run in parallel after ensuring the image is loaded. + * @param {HTMLImageElement} image - an element (must be crossOrigin-safe) + * @returns {Promise<{similarity_hash: string, base_colors: string}>} + */ + async function processImage(image, maxColors = 4) { + const [similarityHash, baseColors] = await Promise.all([ + computeSimilarityHash(image), + extractColors(image, maxColors), + ]); + return { similarity_hash: similarityHash, base_colors: baseColors }; + } + + /* ------------------------------------------------------------------ */ + /* Public API */ + /* ------------------------------------------------------------------ */ + + return { + computeSimilarityHash, + extractColors, + processImage, + }; +})(); + +/* ---------- Expose on global scope (no modules, no build step) ----- */ +window.CP_IMAGE_PROCESSING = ImageProcessing; diff --git a/src/.part5_gallery.js b/src/.part5_gallery.js new file mode 100644 index 0000000..37a904d --- /dev/null +++ b/src/.part5_gallery.js @@ -0,0 +1,713 @@ +/* === Part 5: Gallery View — Album Grouping, Filters, Sidebar Editor, Context Menu === */ + +const Gallery = (() => { + + /* ------------------------------------------------------------------ */ + /* State */ + /* ------------------------------------------------------------------ */ + + /** @type {string} Current working directory path */ + let cwd_ = ''; + + /** @type {Map} In-memory TSV DB keyed by file_id (W-hash) */ + let dbMap_ = new Map(); + + /** @type {Object[]} Flattened row array cached for sort/filter */ + let rows_ = []; + + /** @type {string|null} Currently selected file_id in the sidebar */ + let selectedFileId_ = null; + + /** @type {Map} albumUuid → human-readable album label cache */ + const albumLabels_ = new Map(); + + /* ------------------------------------------------------------------ */ + /* DOM helpers */ + /* ------------------------------------------------------------------ */ + + /** Fetch an element by selector within the gallery view or fail silently */ + function $sel(sel) { + return document.querySelector(sel); + } + + /** Thumbnail URL for a managed file given its file_id and original name */ + function thumbnailUrl(row) { + const ext = (row.original_name || '').split('.').pop() || 'jpg'; + // copyparty serves managed content; use the stored id as filename stem. + return `${CP_CONFIG.base_url || location.origin}${cwd_}/content/managed/${row.file_id}.${ext}`; + } + + /* ------------------------------------------------------------------ */ + /* Album grouping helpers */ + /* ------------------------------------------------------------------ */ + + /** Derive or cache a human-readable label for an album UUID */ + function albumLabel(albumUuid) { + if (!albumUuid) return 'Ungrouped'; + if (albumLabels_.has(albumUuid)) return albumLabels_.get(albumUuid); + + // Look up display name from album mapping, fallback to UUID. + const displayName = AlbumDb.getName(albumUuid); + const members = TsvDb.getByAlbum(dbMap_, albumUuid); + const label = `${displayName} (${members.length})`; + albumLabels_.set(albumUuid, label); + return label; + } + + /** Group rows by album_uuid → Map */ + function groupByAlbum(rows) { + const groups = new Map(); + for (const row of rows) { + const key = row.album_uuid || ''; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(row); + } + return groups; + } + + /* ------------------------------------------------------------------ */ + /* Sorting */ + /* ------------------------------------------------------------------ */ + + /** + * Sort rows by the current order-by criterion. + * @param {string} mode - "created" | "modified" | "similarity" | "color-similarity" + */ + function sortRows(rows, mode) { + const arr = Array.from(rows); + switch (mode) { + case 'created': + // Use modified_at as creation-time proxy (set at import). + arr.sort((a, b) => (Number(a.modified_at) || 0) - (Number(b.modified_at) || 0)); + break; + + case 'modified': + arr.sort((a, b) => (Number(b.modified_at) || 0) - (Number(a.modified_at) || 0)); + break; + + case 'similarity': + // Lexicographic on hex hash — images with similar hashes cluster. + arr.sort((a, b) => String(a.similarity_hash || '').localeCompare(String(b.similarity_hash || ''))); + break; + + case 'color-similarity': + // Group by first base_color hex prefix so similar palettes cluster. + arr.sort((a, b) => { + const ac = (a.base_colors || '').split(',')[0] || ''; + const bc = (b.base_colors || '').split(',')[0] || ''; + return ac.localeCompare(bc); + }); + break; + + default: + break; // no-op, preserve insertion order + } + return arr; + } + + /* ------------------------------------------------------------------ */ + /* Filtering */ + /* ------------------------------------------------------------------ */ + + /** Filter all rows by a comma-or-space-separated tag string (case-insensitive) */ + function filterByTag(rows, needle) { + if (!needle || !needle.trim()) return rows; + const tokens = needle.toLowerCase().split(/[,\s]+/).filter(Boolean); + if (!tokens.length) return rows; + + return rows.filter((row) => { + const tags = (row.user_tags || '').toLowerCase(); + const name = (row.original_name || '').toLowerCase(); + // Match if any token appears in user_tags OR original_name. + return tokens.some((t) => tags.includes(t) || name.includes(t)); + }); + } + + /* ------------------------------------------------------------------ */ + /* Rendering — gallery grid grouped by album */ + /* ------------------------------------------------------------------ */ + + /** Rebuild the entire gallery grid from current state */ + function renderGrid() { + const gridEl = $sel('#gallery-grid'); + if (!gridEl) return; + + const tagFilter = ($sel('#filter-tags')?.value || '').trim(); + const orderBy = $sel('#order-by')?.value || 'created'; + + // 1. Collect all rows, filter, sort. + let filtered = Array.from(dbMap_.values()); + filtered = filterByTag(filtered, tagFilter); + filtered = sortRows(filtered, orderBy); + + // 2. Group by album. + const groups = groupByAlbum(filtered); + + // 3. Sort groups: Ungrouped last, others by earliest member date. + const sortedEntries = Array.from(groups.entries()).sort( + ([aUuid, aMembers], [bUuid, bMembers]) => { + /* Ungrouped (empty uuid) always last */ + if (!aUuid && bUuid) return 1; + if (aUuid && !bUuid) return -1; + const minA = Math.min(...aMembers.map(m => Number(m.modified_at) || 0)); + const minB = Math.min(...bMembers.map(m => Number(m.modified_at) || 0)); + return minB - minA; /* newest group first */ + } + ); + + // 4. Build DOM. + gridEl.innerHTML = ''; + + for (const [albumUuid, members] of sortedEntries) { + if (!members.length) continue; + + // Section heading per album. + const section = document.createElement('section'); + section.className = 'album-section'; + + const heading = document.createElement('h3'); + heading.textContent = albumLabel(albumUuid); + section.appendChild(heading); + + // Responsive sub-grid for this album. + const subGrid = document.createElement('div'); + subGrid.className = 'image-grid'; + + // Sort members: if group has an album UUID, order by album_position; + // otherwise (ungrouped) fall back to modified_at newest-first. + const sorted = Array.from(members).sort((a, b) => { + if (albumUuid && (typeof a.album_position === 'number' || typeof b.album_position === 'number')) { + return (Number(a.album_position) || 0) - (Number(b.album_position) || 0); + } + return (Number(b.modified_at) || 0) - (Number(a.modified_at) || 0); + }); + + for (const row of sorted) { + const img = document.createElement('img'); + img.src = thumbnailUrl(row); + img.alt = row.original_name || ''; + img.loading = 'lazy'; + img.dataset.fileId = row.file_id; + img.addEventListener('click', () => selectImage(row.file_id)); + img.addEventListener('contextmenu', (e) => showContextMenu(e, row.file_id)); + subGrid.appendChild(img); + } + + section.appendChild(subGrid); + gridEl.appendChild(section); + } + + // If no rows at all, show empty-state. + if (!filtered.length) { + const p = document.createElement('p'); + p.style.color = 'var(--text-muted)'; + p.textContent = tagFilter ? 'No items match the current filter.' : 'Gallery is empty — import content first.'; + gridEl.appendChild(p); + } + } + + /* ------------------------------------------------------------------ */ + /* Sidebar editor */ + /* ------------------------------------------------------------------ */ + + /** + * Open the sidebar editor for a given file_id. + * Fields: preview, filename (read-only), album, position, tags. + * On blur of any editable field → save via TsvDb + persist. + */ + function selectImage(fileId) { + selectedFileId_ = fileId; + const row = dbMap_.get(fileId); + if (!row) return; + + // Show editor form, hide empty-state message. + const form = $sel('#gallery-editor-form'); + const empty = $sel('.sidebar-empty'); + if (form) form.style.display = ''; + if (empty) empty.style.display = 'none'; + + // Preview image. + const preview = $sel('#sidebar-preview'); + if (preview) { + preview.innerHTML = ''; + const img = document.createElement('img'); + img.src = thumbnailUrl(row); + img.alt = row.original_name; + img.style.maxWidth = '100%'; + img.style.borderRadius = 'var(--radius)'; + preview.appendChild(img); + } + + // Filename (read-only output). + const fileName = $sel('#sidebar-file-name'); + if (fileName) fileName.value = row.original_name || ''; + + // Album selector — show display name (user-friendly), store UUID. + const albumSelect = $sel('#sidebar-album-select'); + if (albumSelect) { + albumSelect.value = row.album_uuid ? (AlbumDb.getName(row.album_uuid) || '') : ''; + wireBlur(albumSelect, () => { + const rawVal = albumSelect.value.trim(); + let resolvedUuid = ''; + if (rawVal) { + /* Already a UUID? Use as-is */ + if (/^[0-9a-f]{8}-[0-9a-f]{4}/i.test(rawVal)) { + resolvedUuid = rawVal; + } else { + /* Treat as album name — lookup or create new album */ + resolvedUuid = AlbumDb.getUuid(rawVal); + if (!resolvedUuid) { + resolvedUuid = AlbumDb.register(rawVal); + AlbumDb.save(cwd_); + } + } + } + TsvDb.updateUserTags(dbMap_, fileId, ''); // bump timestamp only + const updated = dbMap_.get(fileId); + if (updated) updated.album_uuid = resolvedUuid; + persistAndRerender(); + }); + } + + // Position. + const position = $sel('#sidebar-position'); + if (position) { + position.value = row.album_position !== '' ? String(row.album_position) : ''; + wireBlur(position, () => { + const val = position.value.trim(); + TsvDb.updateUserTags(dbMap_, fileId, ''); // bump timestamp only + const updated = dbMap_.get(fileId); + if (updated) updated.album_position = val ? Number(val) : ''; + persistAndRerender(); + }); + } + + // Tags. + const tagInput = $sel('#sidebar-tag-input'); + if (tagInput) { + tagInput.value = row.user_tags || ''; + wireBlur(tagInput, () => { + const tags = tagInput.value.trim(); + TsvDb.updateUserTags(dbMap_, fileId, tags); + persistAndRerender(); + }); + } + } + + /** Attach a blur handler that fires once per interaction and re-binds */ + function wireBlur(el, handler) { + el.removeEventListener('blur', el._galleryHandler); + el._galleryHandler = handler; + el.addEventListener('blur', handler); + } + + /* ------------------------------------------------------------------ */ + /* Persistence helper */ + /* ------------------------------------------------------------------ */ + + /** Write the full in-memory map to server TSV, then re-render grid */ + async function persistAndRerender() { + try { + await TsvDb.saveFull(cwd_, dbMap_); + renderGrid(); + } catch (err) { + console.error('[Gallery] Failed to persist TSV:', err); + } + } + + /* ------------------------------------------------------------------ */ + /* Context menu */ + /* ------------------------------------------------------------------ */ + + /** @type {string|null} file_id associated with the open context menu */ + let ctxFileId_ = null; + + /** Show the floating context menu at (x, y) for a given file_id */ + function showContextMenu(e, fileId) { + e.preventDefault(); + ctxFileId_ = fileId; + + const menu = $sel('#context-menu'); + if (!menu) return; + + menu.style.display = ''; + menu.style.position = 'fixed'; + // Clamp to viewport so the menu never overflows. + let x = e.clientX; + let y = e.clientY; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + + // After layout, clamp if it exceeds the right/bottom edge. + requestAnimationFrame(() => { + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = `${window.innerWidth - rect.width - 4}px`; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = `${window.innerHeight - rect.height - 4}px`; + } + }); + } + + /** Hide the context menu */ + function hideContextMenu() { + const menu = $sel('#context-menu'); + if (menu) menu.style.display = 'none'; + ctxFileId_ = null; + } + + /* ------------------------------------------------------------------ */ + /* Find Similar — Hash-based Hamming distance */ + /* ------------------------------------------------------------------ */ + + /** Count differing bits between two hex strings */ + function hammingDistance(a, b) { + // Pad to equal length. + const maxLen = Math.max(a.length, b.length); + const xa = a.padStart(maxLen, '0'); + const xb = b.padStart(maxLen, '0'); + + let dist = 0; + for (let i = 0; i < maxLen; i++) { + // XOR the hex digit values. + const xi = parseInt(xa[i], 16) ^ parseInt(xb[i], 16); + // Popcount of one hex digit (0-15). + dist += popcount(xi); + } + return dist; + } + + /** Population count for a single nibble (0..15) */ + function popcount(n) { + let c = 0; + while (n) { n &= n - 1; c++; } + return c; + } + + /** + * Find the N most similar images by perceptual hash. + * @param {string} fileId - source file_id + * @param {number} [n=12] - number of results + */ + async function findSimilarHash(fileId, n = 12) { + const source = dbMap_.get(fileId); + if (!source || !source.similarity_hash) { + alert('No similarity hash available for this file.'); + return; + } + + const results = []; + for (const [, row] of dbMap_) { + if (row.file_id === fileId || !row.similarity_hash) continue; + const dist = hammingDistance(source.similarity_hash, row.similarity_hash); + results.push({ row, dist }); + } + + results.sort((a, b) => a.dist - b.dist); + showSimilarityPanel(results.slice(0, n), 'Hamming distance'); + } + + /* ------------------------------------------------------------------ */ + /* Find Similar — Color-based */ + /* ------------------------------------------------------------------ */ + + /** Parse base_colors string into [{ hex: "#rrggbb", pct: number }] */ + function parseBaseColors(str) { + if (!str) return []; + return str.split(',').map((entry) => { + const [hex, pctStr] = entry.split('_'); + return { hex: hex || '', pct: Number(pctStr) || 0 }; + }).filter(Boolean); + } + + /** Hex color → [r, g, b] */ + function hexToRgb(hex) { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); + if (!m) return [0, 0, 0]; + return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)]; + } + + /** + * Weighted Euclidean distance between two color palettes. + * Uses percentage as weight; shorter palettes are padded with zeros. + */ + function colorDistance(colorsA, colorsB) { + const maxLen = Math.max(colorsA.length, colorsB.length); + let sumDist = 0; + + for (let i = 0; i < maxLen; i++) { + const a = colorsA[i] || { hex: '#000000', pct: 0 }; + const b = colorsB[i] || { hex: '#000000', pct: 0 }; + + const [r1, g1, b1] = hexToRgb(a.hex); + const [r2, g2, b2] = hexToRgb(b.hex); + + // Weighted Euclidean on RGB. + const wt = Math.max(a.pct, b.pct) / 100; // normalised weight + sumDist += Math.sqrt( + (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2 + ) * wt; + } + return sumDist; + } + + /** + * Find the N most similar images by base-color palette. + * @param {string} fileId - source file_id + * @param {number} [n=12] - number of results + */ + async function findSimilarColor(fileId, n = 12) { + const source = dbMap_.get(fileId); + if (!source || !source.base_colors) { + alert('No color data available for this file.'); + return; + } + + const srcColors = parseBaseColors(source.base_colors); + const results = []; + + for (const [, row] of dbMap_) { + if (row.file_id === fileId || !row.base_colors) continue; + const dist = colorDistance(srcColors, parseBaseColors(row.base_colors)); + results.push({ row, dist }); + } + + results.sort((a, b) => a.dist - b.dist); + showSimilarityPanel(results.slice(0, n), 'Color distance'); + } + + /* ------------------------------------------------------------------ */ + /* Similarity result panel (reused by hash & color searches) */ + /* ------------------------------------------------------------------ */ + + /** + * Build a temporary modal-like overlay showing similar-image results. + * @param {Array<{row: Object, dist: number}>} results + * @param {string} metricLabel - label for the score column + */ + function showSimilarityPanel(results, metricLabel) { + // Remove any existing panel first. + removeSimilarityPanel(); + + const overlay = document.createElement('div'); + overlay.id = 'gallery-similarity-overlay'; + overlay.style.cssText = ` + position: fixed; inset: 0; z-index: 9999; + background: rgba(0,0,0,.7); display: flex; align-items: center; justify-content: center; + `; + + const panel = document.createElement('div'); + panel.style.cssText = ` + background: var(--surface); padding: 24px; border-radius: 12px; + max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; + `; + + const title = document.createElement('h3'); + title.textContent = `Similar Items (${metricLabel})`; + panel.appendChild(title); + + if (!results.length) { + const p = document.createElement('p'); + p.style.color = 'var(--text-muted)'; + p.textContent = 'No similar items found.'; + panel.appendChild(p); + } else { + for (const { row, dist } of results) { + const item = document.createElement('div'); + item.style.cssText = 'display:flex; gap:12px; align-items:center; margin-bottom:8px;'; + + const img = document.createElement('img'); + img.src = thumbnailUrl(row); + img.alt = row.original_name; + img.style.cssText = 'width:48px; height:48px; object-fit:cover; border-radius:4px;'; + + const info = document.createElement('div'); + info.style.flex = '1'; + info.style.overflow = 'hidden'; + const nameEl = document.createElement('span'); + nameEl.textContent = row.original_name; + nameEl.style.display = 'block'; + nameEl.style.whiteSpace = 'nowrap'; + nameEl.style.textOverflow = 'ellipsis'; + nameEl.style.overflow = 'hidden'; + info.appendChild(nameEl); + + const score = document.createElement('span'); + score.style.cssText = 'font-size:0.8rem; color:var(--text-muted);'; + score.textContent = `${metricLabel}: ${typeof dist === 'number' ? (dist < 1000 ? dist.toFixed(2) : Math.round(dist)) : dist}`; + info.appendChild(score); + + item.appendChild(img); + item.appendChild(info); + panel.appendChild(item); + } + } + + // Close button. + const closeBtn = document.createElement('button'); + closeBtn.textContent = 'Close'; + closeBtn.style.marginTop = '12px'; + closeBtn.addEventListener('click', () => removeSimilarityPanel()); + panel.appendChild(closeBtn); + + overlay.appendChild(panel); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) removeSimilarityPanel(); + }); + document.body.appendChild(overlay); + } + + /** Remove the similarity overlay from DOM */ + function removeSimilarityPanel() { + const el = $sel('#gallery-similarity-overlay'); + if (el) el.remove(); + } + + /* ------------------------------------------------------------------ */ + /* Trash File */ + /* ------------------------------------------------------------------ */ + + /** + * Delete a file: remove from copyparty, delete from in-memory TSV map, + * and persist the full TSV. + */ + async function trashFile(fileId) { + const row = dbMap_.get(fileId); + if (!row) return; + + if (!confirm(`Delete "${row.original_name}" permanently?`)) return; + + try { + // Determine managed path for the file. + const ext = (row.original_name || '').split('.').pop() || 'jpg'; + const filePath = `${cwd_}/content/managed/${fileId}.${ext}`; + + await CP_CP.deleteFile(filePath); + TsvDb.deleteRow(dbMap_, fileId); + await TsvDb.saveFull(cwd_, dbMap_); + + // Clear sidebar if this file was selected. + if (selectedFileId_ === fileId) { + clearSidebar(); + } + + renderGrid(); + } catch (err) { + console.error(`[Gallery] Failed to trash "${fileId}":`, err); + alert(`Failed to delete file: ${err.message}`); + } + } + + /** Reset sidebar to empty state */ + function clearSidebar() { + selectedFileId_ = null; + const form = $sel('#gallery-editor-form'); + const empty = $sel('.sidebar-empty'); + if (form) form.style.display = 'none'; + if (empty) empty.style.display = ''; + } + + /* ------------------------------------------------------------------ */ + /* Back to Tagging */ + /* ------------------------------------------------------------------ */ + + /** Switch from gallery view back to the tagging view */ + function goBackToTagging() { + const galleryView = $sel('#view-gallery'); + const taggingView = $sel('#view-tagging'); + if (galleryView) galleryView.style.display = 'none'; + if (taggingView) taggingView.style.display = ''; + } + + /* ------------------------------------------------------------------ */ + /* Wire event listeners */ + /* ------------------------------------------------------------------ */ + + function wireEvents() { + // Back to Tagging button. + const backBtn = $sel('#btn-back-tagging'); + if (backBtn) { + backBtn.addEventListener('click', goBackToTagging); + } + + // Tag filter input — debounce re-render on typing. + const filterInput = $sel('#filter-tags'); + if (filterInput) { + let timer; + filterInput.addEventListener('input', () => { + clearTimeout(timer); + timer = setTimeout(renderGrid, 250); + }); + } + + // Order-by select. + const orderBySelect = $sel('#order-by'); + if (orderBySelect) { + orderBySelect.addEventListener('change', renderGrid); + } + + // Context menu actions. + const ctxHashBtn = $sel('#ctx-find-similarity-hash'); + if (ctxHashBtn) { + ctxHashBtn.addEventListener('click', () => { + if (ctxFileId_) findSimilarHash(ctxFileId_); + hideContextMenu(); + }); + } + + const ctxColorBtn = $sel('#ctx-find-similar-color'); + if (ctxColorBtn) { + ctxColorBtn.addEventListener('click', () => { + if (ctxFileId_) findSimilarColor(ctxFileId_); + hideContextMenu(); + }); + } + + const ctxTrashBtn = $sel('#ctx-trash-file'); + if (ctxTrashBtn) { + ctxTrashBtn.addEventListener('click', async () => { + if (ctxFileId_) await trashFile(ctxFileId_); + hideContextMenu(); + }); + } + + // Close context menu on outside click. + document.addEventListener('click', (e) => { + const menu = $sel('#context-menu'); + if (menu && menu.style.display !== 'none' && !menu.contains(e.target)) { + hideContextMenu(); + } + }); + + // Close context menu on Escape key. + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') hideContextMenu(); + }); + } + + /* ------------------------------------------------------------------ */ + /* Public API */ + /* ------------------------------------------------------------------ */ + + return { + /** + * Initialize the gallery view with the given working directory and DB map. + * @param {string} cwd - Copyparty working directory path. + * @param {Map} dbMap - In-memory TSV database keyed by file_id. + */ + init(cwd, dbMap) { + cwd_ = cwd; + dbMap_ = dbMap; + + // Reset album label cache on each init. + albumLabels_.clear(); + + wireEvents(); + renderGrid(); + }, + }; +})(); + +/* ---------- Expose on global scope (no modules, no build step) ----- */ +window.CP_GALLERY = Gallery; diff --git a/src/.part6_album_db.js b/src/.part6_album_db.js new file mode 100644 index 0000000..9c9e297 --- /dev/null +++ b/src/.part6_album_db.js @@ -0,0 +1,157 @@ +/* === Part 6: Album Name Mapping === */ + +const AlbumDb = (() => { + + /* ------------------------------------------------------------------ */ + /* In-memory mapping: Map */ + /* ------------------------------------------------------------------ */ + + let map_ = new Map(); + + /** Path to albums JSON on server */ + function albumsPath(cwd) { + return `${cwd}/db/albums.json`; + } + + /* ------------------------------------------------------------------ */ + /* Load / Persist */ + /* ------------------------------------------------------------------ */ + + /** + * Load the album name↔uuid mapping from server. + * If the file doesn't exist yet, seeds it by scanning existing albums + * in dbMap and generating placeholder names ("Album 1", "Album 2"…). + * @param {string} cwd + * @param {Map} dbMap - TsvDb map keyed by file_id + * @returns {Promise>} name → uuid mapping + */ + async function load(cwd, dbMap) { + try { + const raw = await CP_CP.readFileText(albumsPath(cwd)); + const parsed = JSON.parse(raw); + map_ = new Map(Object.entries(parsed || {})); + } catch (err) { + /* File doesn't exist yet or is invalid — seed from existing albums */ + if (err && (err.status === 404 || err.message.includes('404'))) { + map_ = seedFromDb(dbMap); + await save(cwd); + } else { + console.warn('[AlbumDb] Failed to parse albums.json:', err); + map_ = new Map(); + } + } + return map_; + } + + /** Write current mapping back to server as JSON */ + async function save(cwd) { + const obj = Object.fromEntries(map_); + await CP_CP.writeFile(albumsPath(cwd), JSON.stringify(obj, null, 2), true); + } + + /* ------------------------------------------------------------------ */ + /* CRUD */ + /* ------------------------------------------------------------------ */ + + /** + * Get the UUID for an album name. Returns undefined if not found. + * @param {string} name + * @returns {string|undefined} + */ + function getUuid(name) { + return map_.get(name); + } + + /** + * Reverse lookup: get the display name for a UUID. + * Returns the UUID itself if no mapping exists. + * @param {string} uuid + * @returns {string} + */ + function getName(uuid) { + for (const [name, u] of map_) { + if (u === uuid) return name; + } + return uuid; /* fallback: show raw UUID */ + } + + /** + * Register a new album name and generate a UUID. + * If the name already exists, returns the existing UUID. + * @param {string} name + * @returns {string} uuid + */ + function register(name) { + name = name.trim(); + if (!name) return ''; + if (map_.has(name)) return map_.get(name); + const uuid = TsvDb.generateAlbumUuid(); + map_.set(name, uuid); + return uuid; + } + + /** + * Remove an album name entry. Items remain in DB with their UUIDs. + * @param {string} name + * @returns {boolean} true if removed + */ + function unregister(name) { + return map_.delete(name); + } + + /** + * Return all album names as an array, sorted alphabetically. + * @returns {string[]} + */ + function getAllNames() { + return Array.from(map_.keys()).sort((a, b) => a.localeCompare(b)); + } + + /** Return the internal Map for iteration */ + function getMap() { + return map_; + } + + /* ------------------------------------------------------------------ */ + /* Seeding from existing DB (first-time migration helper) */ + /* ------------------------------------------------------------------ */ + + /** + * Scan all rows in dbMap for existing album_uuids. + * For each unique UUID, generate a placeholder name "Album N". + * Returns a fresh Map. + */ + function seedFromDb(dbMap) { + const uuids = new Set(); + for (const [, row] of dbMap) { + if (row.album_uuid && row.album_uuid.trim()) { + uuids.add(row.album_uuid); + } + } + const seeded = new Map(); + let idx = 1; + for (const uuid of uuids) { + seeded.set(`Album ${idx}`, uuid); + idx++; + } + return seeded; + } + + /* ------------------------------------------------------------------ */ + /* Public API */ + /* ------------------------------------------------------------------ */ + + return { + load, + save, + getUuid, + getName, + register, + unregister, + getAllNames, + getMap, + }; +})(); + +/* ---------- Expose on global scope (no modules, no build step) ----- */ +window.CP_ALBUMDB = AlbumDb; diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..976396a --- /dev/null +++ b/src/app.js @@ -0,0 +1,719 @@ +/* === app.js — Copyparty Content Tag (main application logic) === */ + +(() => { + 'use strict'; + + /* ------------------------------------------------------------------ */ + /* DOM References */ + /* ------------------------------------------------------------------ */ + + const loginForm = document.getElementById('login-form'); + const btnCheck = document.getElementById('btn-check'); + const btnEnter = document.getElementById('btn-enter'); + const loginStatus = document.getElementById('login-status'); + const cwdInput = document.getElementById('cwd-input'); + const passwordInput = document.getElementById('password-input'); + + const viewLogin = document.getElementById('view-login'); + const viewTagging = document.getElementById('view-tagging'); + const viewGallery = document.getElementById('view-gallery'); + const appHeader = document.getElementById('app-header'); + const cwdDisplay = document.getElementById('cwd-display'); + + const btnChangeDir = document.getElementById('btn-change-dir'); + const btnOpenGallery = document.getElementById('btn-open-gallery'); + + const taggingGrid = document.getElementById('tagging-grid'); + const viewerViewport = document.getElementById('viewer-viewport'); + const btnPrev = document.getElementById('btn-prev'); + const btnNext = document.getElementById('btn-next'); + + const albumSelect = document.getElementById('album-select'); + const albumPosition = document.getElementById('album-position'); + const tagInput = document.getElementById('tag-input'); + const tagSuggestions= document.getElementById('tag-suggestions'); + const metaSimHash = document.getElementById('meta-similarity-hash'); + const metaColors = document.getElementById('meta-base-colors'); + const btnDeleteFile = document.getElementById('btn-delete-file'); + + /* ------------------------------------------------------------------ */ + /* State */ + /* ------------------------------------------------------------------ */ + + /** @type {Map} In-memory TSV DB keyed by file_id */ + const dbMap = new Map(); + + /** Array of row objects without user tags — only for grid display */ + let untagged = []; + + /** Current selected row's file_id (null = nothing selected) */ + let currentFileId = null; + + /** Cached permission result from last Check press */ + let cachedPerms = null; + + /* ------------------------------------------------------------------ */ + /* 1. View Switching */ + /* ------------------------------------------------------------------ */ + + /** + * Show exactly one view and optionally the shared header. + * @param {'login'|'tagging'|'gallery'} which - view id suffix + * @param {boolean} [showHeader] - whether #app-header is visible + */ + function switchView(which, showHeader = false) { + const views = [viewLogin, viewTagging, viewGallery]; + views.forEach(v => { if (v) v.style.display = 'none'; }); + + const target = document.getElementById(`view-${which}`); + if (target) target.style.display = ''; + + if (showHeader && appHeader) { + appHeader.style.display = ''; + cwdDisplay.textContent = CP_CONFIG.cwd || ''; + } else if (appHeader) { + appHeader.style.display = 'none'; + } + } + + /* ------------------------------------------------------------------ */ + /* 2. Login View */ + /* ------------------------------------------------------------------ */ + + /** Pre-fill CWD from localStorage on load */ + function restoreLoginFields() { + if (CP_CONFIG.cwd) cwdInput.value = CP_CONFIG.cwd; + if (CP_CONFIG.password) passwordInput.value = CP_CONFIG.password; + } + + /** + * Check button — call CP_CP.checkPerms on the CWD path. + */ + async function handleCheck() { + const cwd = cwdInput.value.trim(); + if (!cwd) { setStatus('Please enter a working directory'); return; } + + try { + const perms = await CP_CP.checkPerms(cwd); + cachedPerms = perms; + if (perms.readable) { + setStatus('OK — readable' + (perms.writable ? ', writable' : ', read-only'), 'ok'); + } else { + setStatus('Not readable', 'error'); + } + } catch (err) { + setStatus(`Error: ${err.message}`, 'error'); + } + } + + /** + * Enter button — store credentials, switch to tagging view. + * Password is optional; only CWD is required. + */ + async function handleEnter() { + const cwd = cwdInput.value.trim(); + const pw = passwordInput.value; + if (!cwd) { setStatus('Please enter a working directory'); return; } + + /* If we haven't checked permissions yet, do it now using current input. + Temporarily set password so checkPerms uses the right PW header. */ + if (!cachedPerms) { + const prevPw = CP_CONFIG.password || ''; + if (pw) CP_CONFIG.setPassword(pw); + else CP_CONFIG.setPassword(''); // ensure no stale password used + try { + cachedPerms = await CP_CP.checkPerms(cwd); + } catch (err) { + setStatus(`Error: ${err.message}`, 'error'); + return; + } finally { + /* Restore previous password so login() sets the intended value */ + if (prevPw) CP_CONFIG.setPassword(prevPw); + else CP_CONFIG.setPassword(''); + } + } + + /* Notify if directory is read-only */ + if (cachedPerms.readable && !cachedPerms.writable) { + setStatus('Warning: read-only directory. Write operations will fail.', ''); + } else if (!cachedPerms.readable) { + setStatus('Cannot enter — directory not readable', 'error'); + return; + } + + CP_AUTH.login(cwd, pw); + switchView('tagging', true); + initTagging(); + } + + /** Set the login status message text and colour class */ + function setStatus(text, type = '') { + loginStatus.textContent = text; + loginStatus.className = 'status-message' + (type ? ` status-${type}` : ''); + } + + /* ------------------------------------------------------------------ */ + /* 3. Tagging View */ + /* ------------------------------------------------------------------ */ + + /** Build an absolute URL to a managed content file */ + function urlFor(fileId, ext) { + // Normalize: strip slashes from each segment, then join with exactly one '/' + const cwd = (CP_CONFIG.cwd || '').replace(/^\/+|\/+$/g, ''); + const file = `${fileId}.${ext}`; + const path = [cwd, 'content', 'managed', file].filter(Boolean).join('/'); + return `${location.origin}/${path}`; + } + + /** Fetch image as blob URL — avoids Chrome canvas-gesture blocking */ + async function fetchImageBlob(fileId, ext) { + const url = urlFor(fileId, ext); + const hdrs = {}; + if (CP_CONFIG.password) hdrs['PW'] = CP_CONFIG.password; + const res = await fetch(url, { headers: hdrs }); + if (!res.ok) throw new Error(`fetch blob ${res.status}`); + const blob = await res.blob(); + return URL.createObjectURL(blob); + } + + /** + * Initialise the tagging view: ensure dirs, load DB, scan unmanaged, + * populate grid with untagged images. + */ + async function initTagging() { + const cwd = CP_CONFIG.cwd; + + try { + // Ensure required directories exist (idempotent) + await Scan.ensureDirs(cwd); + + // Load or create the TSV database in memory + dbMap.clear(); + const loaded = await TsvDb.load(cwd); + loaded.forEach((row, key) => dbMap.set(key, row)); + + // Import any new unmanaged files (populates dbMap) + const result = await Scan.importUnmanaged(cwd, dbMap); + console.log(`[App] Scan result — imported: ${result.imported}, skipped: ${result.skipped}`); + + // Load album name↔uuid mapping (seeds from existing DB if new) + await AlbumDb.load(cwd, dbMap); + + // Re-populate the grid after any imports + populateGrid(); + populateAlbumDatalist(); + } catch (err) { + console.error('[App] initTagging failed:', err); + setStatus(`Init failed: ${err.message}`, 'error'); + } + } + + /** Populate #tagging-grid with images that have no user tags */ + /** If keepViewer is true, the viewer and editor are preserved. */ + function populateGrid(keepViewer) { + taggingGrid.innerHTML = ''; + untagged = []; + + for (const [, row] of dbMap) { + if (!row.user_tags || !row.user_tags.trim()) { + untagged.push(row); + } + } + + if (!untagged.length) { + const note = document.createElement('p'); + note.textContent = 'All items tagged.'; + taggingGrid.appendChild(note); + if (!keepViewer) clearEditor(); + return; + } + + untagged.forEach((row, idx) => { + const img = document.createElement('img'); + const ext = row.original_name.split('.').pop() || 'jpg'; + img.src = urlFor(row.file_id, ext); + img.alt = row.original_name; + img.title = row.original_name; + img.dataset.index = String(idx); + img.dataset.fileid = row.file_id; + img.classList.add('grid-item'); + + img.addEventListener('click', () => selectImage(idx)); + taggingGrid.appendChild(img); + }); + + if (!keepViewer) { + currentFileId = null; + clearEditor(); + } + } + + /** + * Select an image by grid index: load into viewer, process if needed. + * Tracking is by file_id so that save/delete survive grid rebuilds. + * @param {number} idx + */ + async function selectImage(idx) { + // Cancel any pending debounced save from the previous image + clearTimeout(_saveTimer); + _saveTimer = null; + + // Highlight active grid item + taggingGrid.querySelectorAll('.grid-item').forEach(el => el.classList.remove('active')); + const activeImg = taggingGrid.querySelector(`[data-index="${idx}"]`); + if (activeImg) activeImg.classList.add('active'); + + const row = untagged[idx]; + if (!row) return; + + // Track by file_id, not array index — survives grid rebuilds + currentFileId = row.file_id; + + // Load image into the viewer + const ext = row.original_name.split('.').pop() || 'jpg'; + const img = new Image(); + img.src = urlFor(row.file_id, ext); + + viewerViewport.innerHTML = ''; + viewerViewport.appendChild(img); + + // Fill editor with current DB values (may be empty strings) + tagInput.value = row.user_tags || ''; + metaSimHash.textContent = row.similarity_hash || '(not processed)'; + albumSelect.value = AlbumDb.getName(row.album_uuid) || ''; + albumPosition.value = row.album_position !== '' ? row.album_position : ''; + + // Snapshot for dirty-checking — save only if user changes something + editorSnapshot.tags = tagInput.value; + editorSnapshot.albumUuid = albumSelect.value; + editorSnapshot.albumPos = albumPosition.value || ''; + + if (row.base_colors) renderColorSwatches(row.base_colors); + else metaColors.innerHTML = ''; + + btnDeleteFile.disabled = false; + + // Process image if hash or colours are missing. + // Use a blob-fetched copy so Chrome doesn't block canvas readback. + if (!row.similarity_hash || !row.base_colors) { + let blobUrl = null; + try { + blobUrl = await fetchImageBlob(row.file_id, ext); + const procImg = new Image(); + procImg.src = blobUrl; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('processImage: blob load timeout')), 15000); + procImg.addEventListener('load', () => { clearTimeout(timeout); resolve(); }, { once: true }); + procImg.addEventListener('error', () => { clearTimeout(timeout); reject(new Error('processImage: blob load error')); }, { once: true }); + }); + const result = await CP_IMAGE_PROCESSING.processImage(procImg, 4); + + // Update row in dbMap (same object ref) + row.similarity_hash = result.similarity_hash; + row.base_colors = result.base_colors; + dbMap.set(row.file_id, row); + + metaSimHash.textContent = result.similarity_hash; + renderColorSwatches(result.base_colors); + + // Persist to server + await TsvDb.saveFull(CP_CONFIG.cwd, dbMap); + + // Refresh snapshot after save so unchanged form doesn't re-trigger writes + editorSnapshot.tags = tagInput.value; + editorSnapshot.albumUuid = albumSelect.value; + editorSnapshot.albumPos = albumPosition.value || ''; + + // Remove from untagged grid only if user has assigned tags + if (row.user_tags && row.user_tags.trim()) { + removeProcessedGridItem(); + } + } catch (err) { + console.error('[App] processImage failed:', err); + } finally { + if (blobUrl && blobUrl.startsWith('blob:')) { + URL.revokeObjectURL(blobUrl); + } + } + } + } + + /** Remove the just-processed item from the grid, keeping the viewer. */ + function removeProcessedGridItem() { + // Find the currently selected row by file_id (may already be processed) + const activeRow = dbMap.get(currentFileId); + + // Remove the grid DOM element for this item (find by data-fileid if present) + let el = taggingGrid.querySelector(`[data-fileid="${currentFileId}"]`); + if (!el) { + // Fallback: find by index from untagged array + const idx = untagged.findIndex(r => r.file_id === currentFileId); + if (idx >= 0) el = taggingGrid.querySelector(`[data-index="${idx}"]`); + } + if (el) el.remove(); + + // Rebuild the untagged array without the tagged item + const fresh = []; + for (const [, row] of dbMap) { + if (!row.user_tags || !row.user_tags.trim()) { + fresh.push(row); + } + } + untagged = fresh; + + // Update data-index and data-fileid on remaining items + taggingGrid.querySelectorAll('.grid-item').forEach((el, i) => { + el.dataset.index = String(i); + // Rebind click handler since index changed + const clone = el.cloneNode(true); + clone.addEventListener('click', () => selectImage(i)); + el.replaceWith(clone); + }); + + // Update currentFileId to point to the newly processed row in dbMap + if (activeRow) { + currentFileId = activeRow.file_id; + } + + // If all items tagged, show note but keep viewer + if (!fresh.length) { + const note = document.createElement('p'); + note.textContent = 'All items tagged.'; + taggingGrid.appendChild(note); + } + } + + /** Render colour swatch elements from a base_colors string */ + function renderColorSwatches(baseColorsStr) { + metaColors.innerHTML = ''; + if (!baseColorsStr) return; + + const parts = baseColorsStr.split(','); + for (const part of parts) { + const hex = part.split('_')[0]; + if (/^#[0-9a-f]{6}$/i.test(hex)) { + const swatch = document.createElement('span'); + swatch.className = 'swatch'; + swatch.style.backgroundColor = hex; + swatch.title = part; + metaColors.appendChild(swatch); + } + } + } + + /** Find index of the currently selected item in a given array */ + function _findIdx(arr) { + if (!currentFileId) return -1; + return arr.findIndex(r => r.file_id === currentFileId); + } + + /** Navigate to the previous untagged image */ + function prevImage() { + const idx = _findIdx(untagged); + if (idx <= 0) return; + selectImage(idx - 1); + } + + /** Navigate to the next untagged image */ + function nextImage() { + const idx = _findIdx(untagged); + if (idx >= untagged.length - 1) return; + selectImage(idx + 1); + } + + /** Clear the editor panel */ + function clearEditor() { + viewerViewport.innerHTML = ''; + tagInput.value = ''; + metaSimHash.textContent = ''; + albumSelect.value = ''; + albumPosition.value = ''; + metaColors.innerHTML = ''; + btnDeleteFile.disabled = true; + } + + /** Snapshot of editor values at time of last load (for dirty check) */ + let editorSnapshot = { tags: '', albumUuid: '', albumPos: '' }; + + /** Resolve album display value (name or UUID) to a UUID */ + function resolveAlbumValue(value, row) { + const trimmed = (value || '').trim(); + if (!trimmed) return ''; + /* Already a UUID? Use as-is */ + if (/^[0-9a-f]{8}-[0-9a-f]{4}/i.test(trimmed)) return trimmed; + /* Lookup name → uuid; fall back to existing row value */ + const uuid = AlbumDb.getUuid(trimmed); + return uuid || (row && row.album_uuid) || ''; + } + + /** Save the current row's edited tags and album to DB */ + /** Looks up row by file_id in dbMap — survives grid rebuilds. */ + async function saveCurrent() { + if (!currentFileId) return; + const row = dbMap.get(currentFileId); + if (!row) return; + + const currentTags = tagInput.value; + const currentAlbumRaw = albumSelect.value.trim(); + const currentPos = albumPosition.value || ''; + + // Skip write if nothing changed since this image was loaded + if (currentTags === editorSnapshot.tags && + currentAlbumRaw === editorSnapshot.albumUuid && + currentPos === editorSnapshot.albumPos) { + return; + } + + /* Resolve album display value (name) to UUID for storage */ + const resolvedAlbum = resolveAlbumValue(currentAlbumRaw, row); + + TsvDb.updateUserTags(dbMap, row.file_id, currentTags); + const updated = dbMap.get(row.file_id); + if (updated) { + updated.album_uuid = resolvedAlbum; + updated.album_position = currentPos; + } + + await TsvDb.saveFull(CP_CONFIG.cwd, dbMap); + + // Bump snapshot so we don't re-save unchanged values on next call + editorSnapshot.tags = currentTags; + editorSnapshot.albumUuid = currentAlbumRaw; + editorSnapshot.albumPos = currentPos; + } + + /** Resolve album name → uuid and save */ + /** Album select shows names (user-friendly) but DB stores UUIDs. */ + async function resolveAlbumAndSave() { + if (!currentFileId) return; + const row = dbMap.get(currentFileId); + if (!row) return; + + const albumValue = albumSelect.value.trim(); + + /* Resolve to uuid */ + let resolvedUuid = ''; + if (albumValue) { + /* If value is already a UUID, use as-is */ + if (/^[0-9a-f]{8}-[0-9a-f]{4}/i.test(albumValue)) { + resolvedUuid = albumValue; + } else { + /* Treat as album name — lookup or create */ + resolvedUuid = AlbumDb.getUuid(albumValue); + if (!resolvedUuid) { + resolvedUuid = AlbumDb.register(albumValue); + await AlbumDb.save(CP_CONFIG.cwd); + } + } + } + + /* Update row */ + const updated = dbMap.get(currentFileId); + if (updated) updated.album_uuid = resolvedUuid; + + /* Persist */ + await TsvDb.saveFull(CP_CONFIG.cwd, dbMap); + + /* Snapshot: store the display name (what user sees). */ + /* saveCurrent will compare names — dirty-check remains correct. */ + editorSnapshot.albumUuid = albumValue; + editorSnapshot.tags = tagInput.value; + editorSnapshot.albumPos = albumPosition.value || ''; + + /* Refresh datalist in case new album was registered */ + populateAlbumDatalist(); + } + + /** Delete the current file from managed directory and DB */ + async function deleteCurrent() { + if (!currentFileId) return; + const row = dbMap.get(currentFileId); + if (!row) return; + if (!confirm(`Delete "${row.original_name}"?`)) return; + + try { + const ext = row.original_name.split('.').pop() || 'jpg'; + await CP_CP.deleteFile(`${CP_CONFIG.cwd}/content/managed/${row.file_id}.${ext}`); + TsvDb.deleteRow(dbMap, row.file_id); + await TsvDb.saveFull(CP_CONFIG.cwd, dbMap); + + currentFileId = null; + populateGrid(); + clearEditor(); + } catch (err) { + console.error('[App] deleteFile failed:', err); + } + } + + /* ------------------------------------------------------------------ */ + /* 4. Album Datalist & Tag Autocomplete */ + /* ------------------------------------------------------------------ */ + + /** Populate #album-datalist with album names (value → uuid) */ + function populateAlbumDatalist() { + const datalist = document.getElementById('album-datalist'); + if (!datalist) return; + datalist.innerHTML = ''; + + /* Show all registered album names; selecting one gives the UUID */ + for (const name of AlbumDb.getAllNames()) { + const opt = document.createElement('option'); + opt.value = name; + datalist.appendChild(opt); + } + } + + /** Show filtered tag suggestions based on all tags in the DB */ + function showTagSuggestions() { + const inputVal = tagInput.value.toLowerCase().trim(); + tagSuggestions.innerHTML = ''; + + // Collect every tag from the entire database + const allTags = new Set(); + for (const [, row] of dbMap) { + if (row.user_tags) { + row.user_tags.split(',').forEach(t => { + const trimmed = t.trim(); + if (trimmed) allTags.add(trimmed); + }); + } + } + + // Filter: starts with input, not already used in current value + const used = new Set(currentTags()); + const cursorVal = inputVal.split(',').pop().trim(); + for (const tag of allTags) { + if (cursorVal && tag.startsWith(cursorVal) && !used.has(tag)) { + const li = document.createElement('li'); + li.textContent = tag; + li.style.cursor = 'pointer'; + li.addEventListener('click', () => selectTagSuggestion(tag)); + tagSuggestions.appendChild(li); + } + } + } + + /** Split the current tag input into individual tag strings */ + function currentTags() { + return tagInput.value.split(',').map(t => t.trim()).filter(Boolean); + } + + /** Append a selected suggestion to the tag input */ + function selectTagSuggestion(tag) { + const tags = currentTags(); + // Always replace the partial last token with the full suggestion. + // Works whether user typed "tag1, partial" or just "partial". + if (tags.length > 0) tags.pop(); + tags.push(tag); + tagInput.value = tags.join(', ') + ', '; + tagSuggestions.innerHTML = ''; + } + + /* ------------------------------------------------------------------ */ + /* 5. Gallery — dynamic loading */ + /* ------------------------------------------------------------------ */ + + /** Flag tracking whether the gallery module script was loaded */ + let galleryLoaded = false; + + /** Dynamically load .part5_gallery.js via fetch + eval */ + async function loadGalleryModule() { + if (galleryLoaded) return true; + try { + const resp = await fetch('.part5_gallery.js'); + if (!resp.ok) throw new Error(`Gallery module: HTTP ${resp.status}`); + const code = await resp.text(); + const fn = new Function(code); + fn(); // executes the script body — should expose CP_GALLERY + galleryLoaded = true; + return typeof CP_GALLERY !== 'undefined'; + } catch (err) { + console.warn('[App] Gallery module not available:', err.message); + return false; + } + } + + /** Handle "Open Gallery" button click */ + async function openGallery() { + const hasModule = await loadGalleryModule(); + switchView('gallery', true); + if (hasModule && typeof CP_GALLERY.init === 'function') { + try { + CP_GALLERY.init(CP_CONFIG.cwd, dbMap); + } catch (err) { + console.error('[App] Gallery init failed:', err); + } + } else { + console.warn('[App] CP_GALLERY module not loaded — gallery UI inactive'); + } + } + + /** Handle "Back to Tagging" button click */ + function backToTagging() { + switchView('tagging', true); + populateGrid(); + } + + /* ------------------------------------------------------------------ */ + /* 6. Event Binding */ + /* ------------------------------------------------------------------ */ + + // Login form + btnCheck.addEventListener('click', handleCheck); + btnEnter.addEventListener('click', handleEnter); + + // Header + btnChangeDir.addEventListener('click', () => { + cachedPerms = null; + CP_AUTH.logout(); + switchView('login'); + restoreLoginFields(); + }); + btnOpenGallery.addEventListener('click', openGallery); + + // Tagging navigation + btnPrev.addEventListener('click', prevImage); + btnNext.addEventListener('click', nextImage); + + // Back to tagging (gallery view) + const btnBackTagging = document.getElementById('btn-back-tagging'); + if (btnBackTagging) btnBackTagging.addEventListener('click', backToTagging); + + // Editor: save on input change (debounced to avoid thrashing the TSV write) + let _saveTimer = null; + tagInput.addEventListener('input', () => { + showTagSuggestions(); + clearTimeout(_saveTimer); + _saveTimer = setTimeout(() => saveCurrent(), 800); + }); + albumSelect.addEventListener('change', () => { + resolveAlbumAndSave(); + }); + albumPosition.addEventListener('change', saveCurrent); + + // Tag autocomplete keyboard support + tagInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + tagSuggestions.innerHTML = ''; + e.preventDefault(); + } + }); + + // Keyboard shortcuts for image navigation + document.addEventListener('keydown', (e) => { + if (viewTagging.style.display !== 'none' && currentFileId) { + if (e.key === 'ArrowLeft') prevImage(); + if (e.key === 'ArrowRight') nextImage(); + } + }); + + // Delete button + btnDeleteFile.addEventListener('click', deleteCurrent); + + /* ------------------------------------------------------------------ */ + /* 7. Boot */ + /* ------------------------------------------------------------------ */ + + restoreLoginFields(); + +})(); diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..419531e --- /dev/null +++ b/src/index.html @@ -0,0 +1,187 @@ + + + + + + Content Tag + + + + + +
+ +
+ + + + + +
+ + + + + +
+

Untagged Items

+
+
+ + +
+ + +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
    +
    + + + + + +
    +
    + +
    + + +
    + + + + + + + + + + +
    + + + + + + + + + + + + + + + diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..b418f84 --- /dev/null +++ b/src/style.css @@ -0,0 +1,212 @@ +/* ===== Copyparty Content Tag — style.css ===== */ + +/* --- CSS Custom Properties (dark theme matching copyparty shell) --- */ +:root { + --bg: #1a1a2e; + --surface: #24243e; + --surface-elevated: #2c2c4a; + --primary: #e85d04; + --primary-hover: #f48c06; + --danger: #d00000; + --danger-hover: #ef4444; + --text: #e0e0e0; + --text-muted: #a0a0b0; + --border: #3a3a5c; + --focus-ring: #fca311; + --success: #22c55e; + --error: #ef4444; + --radius: 6px; + --shadow: 0 2px 8px rgba(0,0,0,.45); +} + +/* --- Reset + Base --- */ +*, *::before, *::after { box-sizing: border-box; } +body { + margin: 0; padding: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); color: var(--text); + line-height: 1.5; font-size: 14px; +} +button { cursor: pointer; font-family: inherit; } +input, select, textarea, button { font-size: inherit; } + +a { color: var(--primary); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* Screen-reader only utility */ +.sr-only { + position: absolute; width: 1px; height: 1px; + padding: 0; margin: -1px; overflow: hidden; + clip: rect(0,0,0,0); border: 0; +} + +/* Focus ring */ +:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +/* ===== Buttons ===== */ +button, .btn { + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-elevated); + color: var(--text); + font-size: 0.9rem; + transition: background .15s, border-color .15s; +} +button:hover, .btn:hover { + border-color: var(--primary); +} +.btn-primary { + background: var(--primary); color: #fff; border-color: var(--primary); +} +.btn-primary:hover { background: var(--primary-hover); } +.btn-secondary { + background: transparent; color: var(--text-muted); +} +.btn-danger { + background: var(--danger); color: #fff; border-color: var(--danger); +} +.btn-danger:hover { background: var(--danger-hover); } +.btn-icon { + padding: 4px 20px; font-size: 1rem; +} + +/* ===== Login View ===== */ +#view-login { + display: flex; align-items: center; justify-content: center; + min-height: 100vh; +} +.login-card { + background: var(--surface); padding: 32px 36px; + border-radius: 12px; box-shadow: var(--shadow); + max-width: 420px; width: 90%; +} +.login-card h1 { text-align: center; margin-bottom: 24px; } + +.form-group { margin-bottom: 16px; display: flex; flex-direction: column; gap: 4px; } +.form-group label { font-size: 0.85rem; color: var(--text-muted); } +.form-group input, .form-group select { + padding: 8px 12px; border-radius: var(--radius); + background: var(--bg); color: var(--text); + border: 1px solid var(--border); +} +.form-group input:focus, .form-group select:focus { + border-color: var(--focus-ring); outline: none; box-shadow: 0 0 0 3px rgba(252,163,17,.2); +} + +.login-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; } +.status-message { text-align: center; margin-top: 14px; font-style: italic; min-height: 1.5em; } + +/* ===== App Header (shared Tagging + Gallery) ===== */ +#app-header { + display: flex; justify-content: space-between; align-items: center; + height: 52px; padding: 0 24px; background: var(--surface); + border-bottom: 1px solid var(--border); +} +#cwd-display { + text-align: center; flex: 1; + font-weight: 600; color: var(--text-muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +/* ===== Tagging View ===== */ +.tagging-bar { display: flex; justify-content: space-between; padding: 12px 24px; gap: 8px; } +#tagging-grid, .image-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 8px; padding: 0 24px 16px; +} +#tagging-grid img, .image-grid img { + width: 100%; border-radius: var(--radius); cursor: pointer; object-fit: cover; aspect-ratio: 1 / 1; + transition: border-color .15s; + border: 2px solid transparent; +} +#tagging-grid img:hover, .image-grid img:hover { + border-color: var(--primary); +} +.tagging-grid .grid-item.active { + border-color: var(--focus-ring) !important; +} + +/* Viewer area */ +.details-panel { padding: 16px; } +.viewer { + position: relative; display: flex; align-items: center; justify-content: center; gap: 8px; + min-height: 40vh; background: #111; border-radius: var(--radius); overflow: hidden; +} +.viewer-viewport img, .viewer-viewport video { max-width: 90%; max-height: 70vh; object-fit: contain; } +.viewer-arrow { + position: absolute; top: 50%; transform: translateY(-50%); + background: rgba(0,0,0,.6); color: #fff; border: none; font-size: 2rem; line-height: 1; + padding: 8px 12px; z-index: 2; +} +.viewer-arrow:hover { background: rgba(0,0,0,.85); } +.viewer-arrow--left { left: 4px; } +.viewer-arrow--right { right: 4px; } + +/* Tag editor form */ +.editor-form { padding: 16px 24px; display: flex; flex-direction: column; gap: 12px; max-width: 640px; margin: 8px auto 0; } +.editor-form .form-group input[disabled] { opacity: .5; } +.tags-wrapper { position: relative; } + +/* Similarity hash readout */ +.file-meta { display: flex; gap: 16px; flex-wrap: wrap; } +.meta-label { font-size: 0.8rem; color: var(--text-muted); display: block; } +.meta-value { + font-family: Consolas, "Courier New", system-ui, monospace; + font-size: 0.78rem; color: var(--text-muted); word-break: break-all; +} + +/* Color swatches */ +.color-swatches { display: flex; gap: 4px; } +.swatch { + width: 28px; height: 28px; border-radius: 3px; + border: 1px solid var(--border); +} + +/* ===== Gallery View ===== */ +.gallery-bar { padding: 12px 24px; display: flex; align-items: center; gap: 12px; } +.gallery-controls { display: flex; gap: 16px; align-items: last baseline; margin: 16px 24px 0; } + +/* Gallery grid — album sections stacked vertically */ +#gallery-grid { + display: flex; + flex-direction: column; + gap: 24px; + padding: 0 0 16px; +} +.album-section h3 { + margin: 0 24px 8px; + font-size: 1rem; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + padding-bottom: 4px; +} + +/* Grid + sidebar layout */ +.gallery-content { + display: flex; + gap: 16px; + padding: 0 24px 24px; + min-height: 0; +} +.gallery-content > .image-grid { + flex: 1 1 auto; + min-width: 0; +} +.sidebar { + flex: 0 0 280px; + min-width: 0; + position: sticky; + top: 0; + align-self: flex-start; +} + +@media (max-width: 680px) { + .gallery-controls { flex-wrap: wrap; } + .gallery-content { flex-direction: column; } + .sidebar { flex-basis: auto; position: static; width: 100%; } +} diff --git a/tests/api-tests.sh b/tests/api-tests.sh new file mode 100644 index 0000000..652e573 --- /dev/null +++ b/tests/api-tests.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# ============================================================ +# Copyparty Content Tag — API Sanity Tests +# Run against localhost:8086 (user:12345) +# Usage: bash tests/api-tests.sh +# ============================================================ + +set -euo pipefail + +BASE="http://localhost:8086" +PW="12345" +PASS=0 +FAIL=0 + +pass() { PASS=$((PASS+1)); echo " PASS $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL $1"; } + +cleanup() { + # Remove test artifacts if they exist + curl -sf -X POST "${BASE}/private/user/tests_api_tmp?delete&j" \ + -H "PW: ${PW}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "" +echo "=== Copyparty API Sanity Tests ===" +echo "" + +# ------------------------------------------------------------------ +# 1. Authentication +# ------------------------------------------------------------------ +echo "--- Auth ---" + +# Anonymous listing should succeed (read-only) +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${BASE}/?ls") +if [ "$HTTP_CODE" = "200" ]; then + pass "Anonymous GET /?ls → 200" +else + fail "Anonymous GET /?ls → $HTTP_CODE (expected 200)" +fi + +# Authenticated listing with PW header +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${BASE}/?ls" -H "PW: ${PW}") +if [ "$HTTP_CODE" = "200" ]; then + pass "Authenticated GET /?ls + PW header → 200" +else + fail "Authenticated GET /?ls + PW header → $HTTP_CODE (expected 200)" +fi + +# Wrong password → 403 on write endpoints +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${BASE}/private/user/?ls" -H "PW: wrongpassword") +if [ "$HTTP_CODE" = "403" ]; then + pass "Wrong PW → 403" +else + fail "Wrong PW → $HTTP_CODE (expected 403)" +fi + +# ------------------------------------------------------------------ +# 2. Directory listing +# ------------------------------------------------------------------ +echo "--- Directory ---" + +RESP=$(curl -sf "${BASE}/private/user/?ls" -H "PW: ${PW}") +if echo "$RESP" | jq -e '.dirs' >/dev/null 2>&1 || echo "$RESP" | jq -e '.files' >/dev/null 2>&1; then + pass "Directory listing returns JSON with dirs/files" +else + fail "Directory listing missing dirs or files keys" +fi + +# ------------------------------------------------------------------ +# 3. Create directory +# ------------------------------------------------------------------ +echo "--- Mkdir ---" + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${BASE}/private/user/?replace" \ + -H "PW: ${PW}" \ + -d "act=mkdir&name=tests_api_tmp") +if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ]; then + pass "Mkdir → $HTTP_CODE" +else + fail "Mkdir → $HTTP_CODE (expected 200/302)" +fi + +# ------------------------------------------------------------------ +# 4. Write file (PUT + Replace header) +# ------------------------------------------------------------------ +echo "--- Write ---" + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PUT "${BASE}/private/user/tests_api_tmp/testfile.txt?j" \ + -H "PW: ${PW}" \ + -H "Replace: 1" \ + --data-raw "hello world") +if [ "$HTTP_CODE" = "200" ]; then + pass "PUT write (Replace:1) → 200" +else + fail "PUT write (Replace:1) → $HTTP_CODE (expected 200)" +fi + +# Read back the file +CONTENT=$(curl -sf "${BASE}/private/user/tests_api_tmp/testfile.txt?txt" -H "PW: ${PW}") +if [ "$CONTENT" = "hello world" ]; then + pass "Read text returns correct content" +else + fail "Read text returned: $CONTENT (expected 'hello world')" +fi + +# ------------------------------------------------------------------ +# 5. Append to file +# ------------------------------------------------------------------ +echo "--- Append ---" + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PUT "${BASE}/private/user/tests_api_tmp/testfile.txt?apnd&j" \ + -H "PW: ${PW}" \ + --data-raw " appended") +if [ "$HTTP_CODE" = "200" ]; then + pass "Append → 200" +else + fail "Append → $HTTP_CODE (expected 200)" +fi + +CONTENT=$(curl -sf "${BASE}/private/user/tests_api_tmp/testfile.txt?txt" -H "PW: ${PW}") +if [ "$CONTENT" = "hello world appended" ]; then + pass "Appended content correct" +else + fail "Appended content: '$CONTENT' (expected 'hello world appended')" +fi + +# ------------------------------------------------------------------ +# 6. Move file +# ------------------------------------------------------------------ +echo "--- Move ---" + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${BASE}/private/user/tests_api_tmp/testfile.txt?move=/private/user/tests_api_tmp/moved_file.txt" \ + -H "PW: ${PW}") +if [ "$HTTP_CODE" = "200" ]; then + pass "Move → 200" +else + fail "Move → $HTTP_CODE (expected 200)" +fi + +# Verify source gone, dest exists +MOVED=$(curl -sf "${BASE}/private/user/tests_api_tmp/moved_file.txt?txt" -H "PW: ${PW}" 2>/dev/null || echo "") +if [ "$MOVED" = "hello world appended" ]; then + pass "Moved file has correct content" +else + fail "Moved file content: $MOVED" +fi + +# ------------------------------------------------------------------ +# 7. Copy file +# ------------------------------------------------------------------ +echo "--- Copy ---" + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${BASE}/private/user/tests_api_tmp/moved_file.txt?copy=/private/user/tests_api_tmp/copied_file.txt" \ + -H "PW: ${PW}") +if [ "$HTTP_CODE" = "200" ]; then + pass "Copy → 200" +else + fail "Copy → $HTTP_CODE (expected 200)" +fi + +COPIED=$(curl -sf "${BASE}/private/user/tests_api_tmp/copied_file.txt?txt" -H "PW: ${PW}" 2>/dev/null || echo "") +if [ "$COPIED" = "hello world appended" ]; then + pass "Copied file has correct content" +else + fail "Copied file content: $COPIED" +fi + +# ------------------------------------------------------------------ +# 8. Delete file +# ------------------------------------------------------------------ +echo "--- Delete ---" + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${BASE}/private/user/tests_api_tmp/copied_file.txt?delete&j" \ + -H "PW: ${PW}") +if [ "$HTTP_CODE" = "200" ]; then + pass "Delete → 200" +else + fail "Delete → $HTTP_CODE (expected 200)" +fi + +# Verify file gone +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + "${BASE}/private/user/tests_api_tmp/copied_file.txt?txt" -H "PW: ${PW}") +if [ "$HTTP_CODE" = "404" ]; then + pass "Deleted file returns 404" +else + fail "Deleted file still accessible (HTTP $HTTP_CODE)" +fi + +# ------------------------------------------------------------------ +# Cleanup: delete test directory +# ------------------------------------------------------------------ +curl -sf -X POST "${BASE}/private/user/tests_api_tmp?delete&j" \ + -H "PW: ${PW}" >/dev/null 2>&1 || true + +# ------------------------------------------------------------------ +# Summary +# ------------------------------------------------------------------ +echo "" +echo "=== Results: ${PASS} passed, ${FAIL} failed ===" +[ "$FAIL" -eq 0 ] && exit 0 || exit 1 diff --git a/tests/tsv-test.js b/tests/tsv-test.js new file mode 100644 index 0000000..0e5fd3c --- /dev/null +++ b/tests/tsv-test.js @@ -0,0 +1,269 @@ +// ============================================================ +// Copyparty Content Tag — TSV Unit Tests (Node.js) +// Run: node tests/tsv-test.js +// Pure-function tests — no network required. +// ============================================================ + +'use strict'; + +let pass = 0; +let fail = 0; + +function ok(cond, label) { + if (cond) { pass++; console.log(` PASS ${label}`); } + else { fail++; console.error(` FAIL ${label}`); } +} + +// ------------------------------------------------------------------ +// Inline the pure functions from TsvDb for isolated testing +// ------------------------------------------------------------------ + +const TAB = '\t'; + +function escapeField(value) { + return String(value) + .replace(/\\/g, '\\\\') + .replace(/\t/g, '\\t') + .replace(/\n/g, '\\n'); +} + +function unescapeField(raw) { + if (!raw.includes('\\')) return raw; + const out = []; + let i = 0; + while (i < raw.length) { + if (raw[i] === '\\' && i + 1 < raw.length) { + const next = raw[i + 1]; + switch (next) { + case 't': out.push('\t'); break; + case 'n': out.push('\n'); break; + case '\\': out.push('\\'); break; + default: out.push('\\', next); break; + } + i += 2; + } else { + out.push(raw[i]); + i++; + } + } + return out.join(''); +} + +function splitTsvLine(line) { + const result = ['']; + let i = 0; + while (i < line.length) { + if (line[i] === '\\' && i + 1 < line.length) { + result[result.length - 1] += line[i] + line[i + 1]; + i += 2; + } else if (line[i] === TAB) { + result.push(''); + i++; + } else { + result[result.length - 1] += line[i]; + i++; + } + } + return result; +} + +function normalizeTags(tags) { + if (!tags || typeof tags !== 'string') return ''; + return tags.toLowerCase(); +} + +function parseLine(line) { + const raw = splitTsvLine(line); + if (raw.length < 9) return null; + const [file_id, original_name, content_type, similarity_hash, base_colors, + album_uuid, album_position, user_tags, modified_at] = + raw.map(unescapeField); + return { + file_id: String(file_id), + original_name: String(original_name), + content_type: String(content_type), + similarity_hash: String(similarity_hash), + base_colors: String(base_colors), + album_uuid: album_uuid || '', + album_position: album_position !== '' ? Number(album_position) : '', + user_tags: normalizeTags(String(user_tags)), + modified_at: Number(modified_at), + }; +} + +function rowToLine(row) { + const f = []; + f.push(escapeField(row.file_id)); + f.push(escapeField(row.original_name)); + f.push(escapeField(String(row.content_type))); + f.push(escapeField(String(row.similarity_hash))); + f.push(escapeField(String(row.base_colors))); + f.push(escapeField(String(row.album_uuid || ''))); + f.push(escapeField(String(row.album_position !== '' ? row.album_position : ''))); + f.push(escapeField(normalizeTags(String(row.user_tags)))); + f.push(escapeField(String(Number(row.modified_at || (Date.now() / 1000 | 0))))); + return f.join(TAB); +} + +function parse(rawText) { + const result = new Map(); + if (!rawText || !rawText.trim()) return result; + for (const line of rawText.split(/\r?\n/)) { + if (!line.trim()) continue; + const row = parseLine(line); + if (row) result.set(row.file_id, row); + } + return result; +} + +function serialize(map) { + if (!map.size) return ''; + const lines = []; + for (const [, row] of map) { + lines.push(rowToLine(row)); + } + return lines.join('\n') + '\n'; +} + +function getByTag(map, tagString) { + const needle = normalizeTags(String(tagString)); + const result = []; + for (const [, row] of map) { + if (row.user_tags.includes(needle)) result.push(row); + } + return result; +} + +function getByAlbum(map, album_uuid) { + const needle = String(album_uuid); + const result = []; + for (const [, row] of map) { + if (String(row.album_uuid) === needle) result.push(row); + } + return result; +} + +function updateUserTags(map, file_id, newCommaSepTags) { + const row = map.get(file_id); + if (!row) return false; + const oldTs = row.modified_at; + row.user_tags = normalizeTags(String(newCommaSepTags)); + row.modified_at = Date.now() / 1000 | 0; + return row.modified_at > oldTs; +} + +function deleteRow(map, file_id) { + return map.delete(file_id); +} + +// ------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------ + +console.log('\n=== TSV Unit Tests ===\n'); + +// --- Helper to build a test fixture --- +const ALBUM_UUID = '550e8400-e29b-41d4-a716-446655440000'; +const FIXTURE_ROWS = [ + { file_id: 'AAA111', original_name: 'sunset.jpg', content_type: 'image', + similarity_hash: '0a1b2c3d', base_colors: '#f5a623_45,#234_15', + album_uuid: ALBUM_UUID, album_position: 0, user_tags: 'sunset, beach', + modified_at: 1700000000 }, + { file_id: 'BBB222', original_name: 'ocean.png', content_type: 'image', + similarity_hash: '1b2c3d4e', base_colors: '#4a90d9_60,#fffef2_30', + album_uuid: '', album_position: '', user_tags: 'ocean, waves, beach', + modified_at: 1700001000 }, +]; + +// --- 1. Parse valid TSV --- +console.log('--- Parse ---'); +const fixtureTsv = FIXTURE_ROWS.map(rowToLine).join('\n') + '\n'; +const parsed = parse(fixtureTsv); +ok(parsed.size === 2, 'Parse two rows → Map size 2'); +ok(parsed.has('AAA111'), 'Parsed row AAA111 exists'); +ok(parsed.has('BBB222'), 'Parsed row BBB222 exists'); +const rowA = parsed.get('AAA111'); +ok(rowA.original_name === 'sunset.jpg', 'Row A original_name correct'); +ok(rowA.content_type === 'image', 'Row A content_type = image'); +ok(rowA.album_uuid === ALBUM_UUID, 'Row A album_uuid matches'); +ok(rowA.album_position === 0, 'Row A album_position = 0'); +ok(rowA.user_tags === 'sunset, beach', 'Row A user_tags normalized'); +ok(rowA.modified_at === 1700000000, 'Row A modified_at correct'); + +// --- 2. Serialize roundtrip --- +console.log('\n--- Serialize Roundtrip ---'); +const serialized = serialize(parsed); +const reparsed = parse(serialized); +ok(reparsed.size === parsed.size, 'Roundtrip preserves Map size'); +for (const [key, val] of parsed) { + const again = reparsed.get(key); + ok( + JSON.stringify(val) === JSON.stringify(again), + `Roundtrip row ${key} identical` + ); +} + +// --- 3. Escape / Unescape roundtrip --- +console.log('\n--- Escape/Unescape ---'); +ok(escapeField('hello\tworld') === 'hello\\tworld', 'Tab escaped'); +ok(escapeField('line1\nline2') === 'line1\\nline2', 'Newline escaped'); +ok(escapeField('back\\\\slash') === 'back\\\\\\\\slash', 'Backslash double-escaped'); +ok(unescapeField('hello\\tworld') === 'hello\tworld', 'Tab unescaped'); +ok(unescapeField('line1\\nline2') === 'line1\nline2', 'Newline unescaped'); +ok(unescapeField('a\\\\b') === 'a\\b', 'Backslash roundtrip'); + +// Row with special chars in filename: +const specialRow = { ...FIXTURE_ROWS[0], original_name: 'tab\there.txt' }; +const specialLine = rowToLine(specialRow); +const specialParsed = parseLine(specialLine); +ok(specialParsed.original_name === 'tab\there.txt', 'Filename with tab survives roundtrip'); + +// --- 4. getByTag --- +console.log('\n--- getByTag ---'); +const beachRows = getByTag(parsed, 'beach'); +ok(beachRows.length === 2, 'getByTag("beach") → 2 rows (both have beach)'); +const oceanRows = getByTag(parsed, 'ocean'); +ok(oceanRows.length === 1, 'getByTag("ocean") → 1 row'); +ok(oceanRows[0].file_id === 'BBB222', 'getByTag("ocean") returns BBB222'); +const noneRows = getByTag(parsed, 'mountain'); +ok(noneRows.length === 0, 'getByTag("mountain") → 0 rows'); + +// --- 5. getByAlbum --- +console.log('\n--- getByAlbum ---'); +const albumRows = getByAlbum(parsed, ALBUM_UUID); +ok(albumRows.length === 1, 'getByAlbum(uuid) → 1 row'); +ok(albumRows[0].file_id === 'AAA111', 'getByAlbum returns AAA111'); +ok(getByAlbum(parsed, 'nonexistent').length === 0, 'Unknown album → 0 rows'); + +// --- 6. updateUserTags --- +console.log('\n--- updateUserTags ---'); +const oldTs = parsed.get('AAA111').modified_at; +updateUserTags(parsed, 'AAA111', 'new tag'); +ok(parsed.get('AAA111').user_tags === 'new tag', 'Tags updated'); +ok(parsed.get('AAA111').modified_at > oldTs, 'Timestamp bumped'); +ok(!updateUserTags(parsed, 'NONEXIST'), 'Updating nonexistent row → false'); + +// --- 7. deleteRow --- +console.log('\n--- deleteRow ---'); +ok(deleteRow(parsed, 'BBB222') === true, 'Delete existing → true'); +ok(!parsed.has('BBB222'), 'Row removed from Map'); +ok(deleteRow(parsed, 'BBB222') === false, 'Delete again → false'); + +// --- 8. Malformed rows skipped --- +console.log('\n--- Malformed Rows ---'); +const badTsv = 'only\ttwo\tcolumns\n' + rowToLine(FIXTURE_ROWS[0]); +const mixedParse = parse(badTsv); +ok(mixedParse.size === 1, 'Malformed row skipped, valid row parsed'); + +// --- 9. Empty input --- +console.log('\n--- Empty Input ---'); +ok(parse('').size === 0, 'Empty string → empty Map'); +ok(parse(null).size === 0, 'null → empty Map'); +ok(parse(' \n \n ').size === 0, 'Whitespace-only → empty Map'); +ok(serialize(new Map()) === '', 'Serialize empty Map → empty string'); + +// ------------------------------------------------------------------ +// Summary +// ------------------------------------------------------------------ +console.log(`\n=== Results: ${pass} passed, ${fail} failed ===\n`); +process.exit(fail > 0 ? 1 : 0);