This commit is contained in:
Frog-Lover-Antony
2026-07-18 17:40:47 +03:00
parent 4b3c1d762d
commit 6f9ca3864f
19 changed files with 3714 additions and 3 deletions
+86
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
.omo
.claude
.codegraph
+86 -2
View File
@@ -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: <password>` 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=<dir>` → 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) ###
+145 -1
View File
@@ -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 |
+17
View File
@@ -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
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+227
View File
@@ -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;
})();
+241
View File
@@ -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<file_id, RowObject> */
// 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,
};
})();
+149
View File
@@ -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<void>}
*/
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<string, Object>} 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,
};
})();
+295
View File
@@ -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 <canvas>, 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 <img> 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;
+713
View File
@@ -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<string, Object>} 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<string, string>} 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<uuid | '', Object[]> */
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<string, Object>} 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;
+157
View File
@@ -0,0 +1,157 @@
/* === Part 6: Album Name Mapping === */
const AlbumDb = (() => {
/* ------------------------------------------------------------------ */
/* In-memory mapping: Map<name, uuid> */
/* ------------------------------------------------------------------ */
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<string, Object>} dbMap - TsvDb map keyed by file_id
* @returns {Promise<Map<string, string>>} 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<name, uuid>.
*/
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;
+719
View File
@@ -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<string, Object>} 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();
})();
+187
View File
@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Content Tag</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- ==================== LOGIN VIEW ==================== -->
<main id="view-login" data-view="login">
<section class="login-card">
<h1>Content Tag</h1>
<form id="login-form" autocomplete="off">
<div class="form-group">
<label for="cwd-input">Working Directory</label>
<input type="text" id="cwd-input" placeholder="/private/user/media" autofill-off>
</div>
<div class="form-group">
<label for="password-input">Password</label>
<input type="password" id="password-input" placeholder="Copyparty password">
</div>
<div class="login-actions">
<button type="button" id="btn-check">Check</button>
<button type="button" id="btn-enter">Enter</button>
</div>
<p id="login-status" class="status-message" aria-live="polite"></p>
</form>
</section>
</main>
<!-- ==================== APP HEADER (shared by Tagging + Gallery) ==================== -->
<header id="app-header" style="display:none">
<!-- CWD breadcrumb (centered) -->
<output id="cwd-display" aria-label="Current directory"></output>
<!-- Change-directory button (right-aligned) -->
<button type="button" id="btn-change-dir" class="btn-icon" title="Change working directory">&#9875;</button>
</header>
<!-- ==================== TAGGING VIEW ==================== -->
<main id="view-tagging" data-view="tagging" style="display:none">
<!-- Top bar: gallery button -->
<nav class="tagging-bar">
<button type="button" id="btn-open-gallery">Open Gallery</button>
</nav>
<!-- Untagged image grid -->
<section aria-label="Untagged content">
<h2>Untagged Items</h2>
<div id="tagging-grid" class="image-grid"></div>
</section>
<!-- Image viewer + editor panel -->
<section id="tagging-details" class="details-panel">
<!-- Viewer with prev/next arrows -->
<figure class="viewer">
<button type="button" id="btn-prev" class="viewer-arrow viewer-arrow--left" aria-label="Previous">&#9664;</button>
<div id="viewer-viewport" class="viewer-viewport"></div>
<button type="button" id="btn-next" class="viewer-arrow viewer-arrow--right" aria-label="Next">&#9654;</button>
</figure>
<!-- Metadata editor -->
<form id="tag-editor-form" class="editor-form">
<div class="form-group">
<label for="album-select">Album</label>
<input type="text" id="album-select" list="album-datalist" placeholder="Select or create album">
<datalist id="album-datalist"></datalist>
</div>
<div class="form-group">
<label for="album-position">Position</label>
<input type="number" id="album-position" min="0" step="1" disabled placeholder="Auto-assigned">
</div>
<div class="form-group tags-wrapper">
<label for="tag-input">Tags</label>
<input type="text" id="tag-input" placeholder="sunset, beach, golden hour&#8203;" autocomplete="off">
<ul id="tag-suggestions" class="suggestions-dropdown" role="listbox"></ul>
</div>
<!-- Read-only metadata -->
<aside class="file-meta">
<div class="meta-block">
<span class="meta-label">Similarity Hash</span>
<output id="meta-similarity-hash" class="meta-value" aria-live="polite"></output>
</div>
<div class="meta-block">
<span class="meta-label">Base Colors</span>
<div id="meta-base-colors" class="color-swatches" aria-label="Extracted image colors"></div>
</div>
</aside>
<button type="button" id="btn-delete-file" class="btn-danger" disabled>Delete File</button>
</form>
</section>
</main>
<!-- ==================== GALLERY VIEW ==================== -->
<main id="view-gallery" data-view="gallery" style="display:none">
<!-- Gallery top bar -->
<nav class="gallery-bar">
<button type="button" id="btn-back-tagging">Back to Tagging</button>
</nav>
<!-- Filters and ordering -->
<section class="gallery-controls">
<div class="control-group">
<label for="filter-tags">Filter by tag / album</label>
<input type="text" id="filter-tags" placeholder="Type to filter..." autocomplete="off">
</div>
<div class="control-group">
<label for="order-by">Order by</label>
<select id="order-by">
<option value="created">Creation Time</option>
<option value="modified">Modification Time</option>
<option value="similarity">Similarity Hash</option>
<option value="color-similarity">Color Similarity</option>
</select>
</div>
</section>
<!-- Gallery image grid (grouped by album) + Sidebar tag editor -->
<div class="gallery-content">
<section aria-label="Gallery images">
<div id="gallery-grid" class="image-grid"></div>
</section>
<aside id="gallery-sidebar" class="sidebar">
<div class="sidebar-empty">Select an image to edit tags</div>
<!-- Populated on image select -->
<form id="gallery-editor-form" style="display:none">
<figure id="sidebar-preview"></figure>
<div class="form-group">
<label for="sidebar-file-name">Filename</label>
<output id="sidebar-file-name" class="meta-value"></output>
</div>
<div class="form-group">
<label for="sidebar-album-select">Album</label>
<input type="text" id="sidebar-album-select" list="album-datalist" placeholder="Select or create album">
</div>
<div class="form-group">
<label for="sidebar-position">Position</label>
<input type="number" id="sidebar-position" min="0" step="1">
</div>
<div class="form-group tags-wrapper">
<label for="sidebar-tag-input">Tags</label>
<input type="text" id="sidebar-tag-input" placeholder="comma-separated tags&#8203;" autocomplete="off">
<ul id="sidebar-tag-suggestions" class="suggestions-dropdown" role="listbox"></ul>
</div>
</form>
</aside>
</div>
</main>
<!-- ==================== CONTEXT MENU (floating overlay) ==================== -->
<div id="context-menu" class="context-menu" role="menu" style="display:none">
<button type="button" id="ctx-find-similarity-hash" role="menuitem">&#9734; Find Similar (Hash)</button>
<button type="button" id="ctx-find-similar-color" role="menuitem">&#128065; Find Similar (Color)</button>
<button type="button" id="ctx-trash-file" role="menuitem">&#128465; Trash File</button>
</div>
<!-- Part modules (deferred; expose globals before app.js runs) -->
<script defer src=".part1_config_auth.js"></script>
<script defer src=".part2_tsv_db.js"></script>
<script defer src=".part3_scan_import.js"></script>
<script defer src=".part4_image_processing.js"></script>
<script defer src=".part6_album_db.js"></script>
<!-- App logic (deferred so DOM is ready) -->
<script defer src="app.js"></script>
</body>
</html>
+212
View File
@@ -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%; }
}
+208
View File
@@ -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
+269
View File
@@ -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);