v0.1
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
.omo
|
||||
.claude
|
||||
.codegraph
|
||||
@@ -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) ###
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
|
||||
})();
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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">⚓</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">◀</button>
|
||||
<div id="viewer-viewport" class="viewer-viewport"></div>
|
||||
<button type="button" id="btn-next" class="viewer-arrow viewer-arrow--right" aria-label="Next">▶</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​" 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​" 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">☆ Find Similar (Hash)</button>
|
||||
<button type="button" id="ctx-find-similar-color" role="menuitem">👁 Find Similar (Color)</button>
|
||||
<button type="button" id="ctx-trash-file" role="menuitem">🗑 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
@@ -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%; }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user