diff --git a/photomap/backend/preferences.py b/photomap/backend/preferences.py index 37ad4280..b0f2d1c8 100644 --- a/photomap/backend/preferences.py +++ b/photomap/backend/preferences.py @@ -66,6 +66,15 @@ class UserPreferences(_CamelModel): show_metadata_fields: bool = True autotagging_enabled: bool = False + # Dataset Curator panel. Ranges mirror the HTML input min/max attrs in + # templates/modules/curation.html — the frontend clamps too, but server + # validation defends against a stale tab posting an out-of-range value + # after a future HTML change tightens the bounds. + curation_target_count: int = Field(default=80, ge=10, le=1000) + curation_iterations: int = Field(default=20, ge=1, le=30) + curation_method: Literal["fps", "kmeans"] = "fps" + curation_exclude_threshold: int = Field(default=90, ge=1, le=100) + # Stragglers currently in localStorage album: str | None = None curation_export_path: str | None = None diff --git a/photomap/frontend/static/javascript/curation.js b/photomap/frontend/static/javascript/curation.js index d7f3ad16..f652df6e 100644 --- a/photomap/frontend/static/javascript/curation.js +++ b/photomap/frontend/static/javascript/curation.js @@ -4,7 +4,14 @@ import { toggleGridSwiperView } from "./events.js"; import { createSimpleDirectoryPicker } from "./filetree.js"; import { updateSearchCheckmarks } from "./search-ui.js"; import { slideState } from "./slide-state.js"; -import { state } from "./state.js"; +import { + setCurationExcludeThreshold, + setCurationExportPath, + setCurationIterations, + setCurationMethod, + setCurationTargetCount, + state, +} from "./state.js"; import { highlightCurationSelection, setCurationMode, @@ -205,17 +212,35 @@ function setupEventListeners() { return; } - // Load saved export path from localStorage - const savedPath = localStorage.getItem("curationExportPath"); - if (savedPath) { - exportPathInput.value = savedPath; + const thresholdInput = document.getElementById("lockThresholdInput"); + const methodFps = document.getElementById("methodFps"); + const methodKmeans = document.getElementById("methodKmeans"); + + // Restore every curator field from the persisted state so reopening + // the panel (or revisiting the app on another day) shows the same + // values the user left. State has already been populated from + // localStorage and reconciled with the server by state.js before this + // function runs. + number.value = state.curationTargetCount; + slider.value = state.curationTargetCount; + iterationsInput.value = state.curationIterations; + if (state.curationMethod === "kmeans") { + methodKmeans.checked = true; + } else { + methodFps.checked = true; + } + if (thresholdInput) { + thresholdInput.value = state.curationExcludeThreshold; + } + if (state.curationExportPath) { + exportPathInput.value = state.curationExportPath; validateExportPath(); } // Monitor export path changes exportPathInput.oninput = () => { const path = exportPathInput.value.trim(); - localStorage.setItem("curationExportPath", path); + setCurationExportPath(path); validateExportPath(); }; @@ -229,7 +254,7 @@ function setupEventListeners() { createSimpleDirectoryPicker( (selectedPath) => { exportPathInput.value = selectedPath; - localStorage.setItem("curationExportPath", selectedPath); + setCurationExportPath(selectedPath); validateExportPath(); }, currentPath, @@ -256,12 +281,50 @@ function setupEventListeners() { }; } - slider.oninput = () => (number.value = slider.value); - number.oninput = () => (slider.value = number.value); + // Slider <-> number input are bidirectionally synced; both also persist + // the change so the value survives a panel close + reopen (and the + // server PATCH lets it survive a localStorage eviction on iOS). + slider.oninput = () => { + number.value = slider.value; + const clamped = Math.max(10, Math.min(1000, parseInt(slider.value, 10) || 80)); + setCurationTargetCount(clamped); + }; + number.oninput = () => { + slider.value = number.value; + const clamped = Math.max(10, Math.min(1000, parseInt(number.value, 10) || 80)); + setCurationTargetCount(clamped); + }; + iterationsInput.oninput = () => { + // HTML caps at 30 already (and the run-button handler also re-clamps); + // mirror the same clamp here so the persisted value can't drift past + // what the model accepts (server validates ge=1, le=30). + const clamped = Math.max(1, Math.min(30, parseInt(iterationsInput.value, 10) || 20)); + setCurationIterations(clamped); + }; + if (thresholdInput) { + thresholdInput.oninput = () => { + const clamped = Math.max(1, Math.min(100, parseInt(thresholdInput.value, 10) || 90)); + setCurationExcludeThreshold(clamped); + }; + } + // Method radios. The change event fires for the radio that becomes + // selected; persist the selected value as a string. + if (methodFps) { + methodFps.onchange = () => { + if (methodFps.checked) { + setCurationMethod("fps"); + } + }; + } + if (methodKmeans) { + methodKmeans.onchange = () => { + if (methodKmeans.checked) { + setCurationMethod("kmeans"); + } + }; + } closeBtn.onclick = window.toggleCurationPanel; - // Radio buttons don't need click handlers - they work automatically - clearBtn.onclick = () => { clearSelectionData(); analysisResults = []; diff --git a/photomap/frontend/static/javascript/state.js b/photomap/frontend/static/javascript/state.js index 030927a1..58b632c6 100644 --- a/photomap/frontend/static/javascript/state.js +++ b/photomap/frontend/static/javascript/state.js @@ -45,6 +45,14 @@ export const state = { umapControlsVisible: true, // Whether the UMAP controls panel is visible showMetadataFields: true, // Whether the metadata-drawer fields table is shown autotaggingEnabled: false, // Whether to build the vocab index and show cluster/image labels + // Dataset Curator panel state. The curator panel reads these on open and + // writes them through the standard PERSISTED_SETTINGS setters on every + // input change, so the next visit reopens the panel with the same values. + curationTargetCount: 80, // [10, 1000] + curationIterations: 20, // [1, 30] + curationMethod: "fps", // "fps" (Diversity) or "kmeans" (Blocks) + curationExcludeThreshold: 90, // [1, 100] — the % match threshold for "Exclude Matches" + curationExportPath: "", // last-used export folder }; // --------------------------------------------------------------------------- @@ -118,6 +126,12 @@ const PERSISTED_SETTINGS = [ default: false, onSet: (value) => setAutotaggingEnabledInLabels(value), }, + // Dataset Curator + { key: "curationTargetCount", type: "int", default: 80 }, + { key: "curationIterations", type: "int", default: 20 }, + { key: "curationMethod", type: "string", default: "fps" }, + { key: "curationExcludeThreshold", type: "int", default: 90 }, + { key: "curationExportPath", type: "string", default: "" }, ]; function _parseStored(raw, type) { @@ -437,6 +451,11 @@ export const setUmapClickSelectsCluster = _setters.umapClickSelectsCluster; export const setUmapControlsVisible = _setters.umapControlsVisible; export const setShowMetadataFields = _setters.showMetadataFields; export const setAutotaggingEnabled = _setters.autotaggingEnabled; +export const setCurationTargetCount = _setters.curationTargetCount; +export const setCurationIterations = _setters.curationIterations; +export const setCurationMethod = _setters.curationMethod; +export const setCurationExcludeThreshold = _setters.curationExcludeThreshold; +export const setCurationExportPath = _setters.curationExportPath; export async function setAlbum(newAlbumKey, force = false) { if (force || state.album !== newAlbumKey) { diff --git a/tests/backend/test_preferences.py b/tests/backend/test_preferences.py index 8de1ca84..65fb5fa2 100644 --- a/tests/backend/test_preferences.py +++ b/tests/backend/test_preferences.py @@ -45,6 +45,12 @@ def test_get_without_cookie_mints_one_and_returns_defaults(client: TestClient): assert body["autotaggingEnabled"] is False assert body["updatedAt"] == 0.0 assert body["album"] is None + # Curator defaults mirror the HTML input values in curation.html. + assert body["curationTargetCount"] == 80 + assert body["curationIterations"] == 20 + assert body["curationMethod"] == "fps" + assert body["curationExcludeThreshold"] == 90 + assert body["curationExportPath"] is None def test_patch_then_get_returns_merged(client: TestClient): @@ -102,6 +108,48 @@ def test_patch_invalid_value_returns_422(client: TestClient): assert response.status_code == 422 +def test_curation_fields_round_trip(client: TestClient): + """All five Dataset Curator fields persist and come back unchanged.""" + client.get("/preferences/") + response = client.patch( + "/preferences/", + json={ + "curationTargetCount": 250, + "curationIterations": 15, + "curationMethod": "kmeans", + "curationExcludeThreshold": 75, + "curationExportPath": "/tmp/curated", + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["curationTargetCount"] == 250 + assert body["curationIterations"] == 15 + assert body["curationMethod"] == "kmeans" + assert body["curationExcludeThreshold"] == 75 + assert body["curationExportPath"] == "/tmp/curated" + + fetched = client.get("/preferences/").json() + assert fetched["curationTargetCount"] == 250 + assert fetched["curationMethod"] == "kmeans" + assert fetched["curationExportPath"] == "/tmp/curated" + + +def test_curation_invalid_values_return_422(client: TestClient): + client.get("/preferences/") + # target count must be 10..1000 + assert client.patch("/preferences/", json={"curationTargetCount": 5}).status_code == 422 + assert client.patch("/preferences/", json={"curationTargetCount": 2000}).status_code == 422 + # iterations must be 1..30 + assert client.patch("/preferences/", json={"curationIterations": 0}).status_code == 422 + assert client.patch("/preferences/", json={"curationIterations": 50}).status_code == 422 + # threshold must be 1..100 + assert client.patch("/preferences/", json={"curationExcludeThreshold": 0}).status_code == 422 + assert client.patch("/preferences/", json={"curationExcludeThreshold": 200}).status_code == 422 + # method is a Literal + assert client.patch("/preferences/", json={"curationMethod": "magic"}).status_code == 422 + + def test_two_clients_are_isolated(): """Each TestClient session gets a separate cookie, so prefs don't leak.""" from photomap.backend.photomap_server import app