Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions photomap/backend/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 74 additions & 11 deletions photomap/frontend/static/javascript/curation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
};

Expand All @@ -229,7 +254,7 @@ function setupEventListeners() {
createSimpleDirectoryPicker(
(selectedPath) => {
exportPathInput.value = selectedPath;
localStorage.setItem("curationExportPath", selectedPath);
setCurationExportPath(selectedPath);
validateExportPath();
},
currentPath,
Expand All @@ -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 = [];
Expand Down
19 changes: 19 additions & 0 deletions photomap/frontend/static/javascript/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
48 changes: 48 additions & 0 deletions tests/backend/test_preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down