Просмотр исходного кода

Add photo search and indexing with SQLite backend (#245)

* Add SQLite-backed photo search with autocomplete and auto-indexing

Adds an iOS Photos–style search feature to the Photo web app, backed by a
per-user SQLite index built through the AGI sqlite library.

Search index (backend/imagedb.js)
- Designs a `photos` schema indexing file name, resolution (width/height/
  megapixels/orientation), shooting parameters (camera make/model, lens, ISO,
  aperture, shutter, focal length), the date taken (EXIF DateTimeOriginal, used
  as the photo's created date) and the file modified date — plus an index_meta
  table for schema version / exclude list / last-index time.
- Robustly extracts metadata via imagelib (dimensions + EXIF) and filelib
  (mtime/size), normalising goexif's JSON-encoded values; stored per user at
  user:/.appdata/photo/photoindex.db.

Auto indexing (backend/indexPhotos.js)
- Incremental indexer: walks the photo library roots, (re)indexes only new or
  changed files in bounded batches and prunes deleted ones. The app kicks this
  off in the background on load and loops until done, so the index stays fresh
  automatically. "full" mode wipes and rebuilds.

Search + autocomplete (backend/searchPhotos.js, searchSuggest.js)
- Free-text query parser supporting tokens like `iso:1600`, `f/2.8`, `50mm`,
  `model:"EOS R5"`, `2023`, `landscape`, `.jpg`, `modified:>2024-01-01`, plus
  plain text over name/camera/lens; compiled to parameterised SQL.
- Autocomplete returns ranked, de-duplicated suggestions (cameras, lenses,
  years, file types, file names, filter tokens) drawn from the index.

Front-end (index.html, search.js, photo.js)
- Adds a search bar with autocomplete dropdown, keyboard navigation, result
  count and a background-indexing status chip with a Rebuild action; results
  reuse the existing grid and viewer.

Also implements getExcludeFolders/setExcludeFolders in imagedb.js, which the
existing backend/exclude.js included but were previously missing; the indexer
honours the exclude list.

https://claude.ai/code/session_014XBV87rHxBy1BHwn89rgd8

* Photo search: tags/chips input and month searching

Turns the photo search box into a tags input and adds calendar-month search.

Tags input (index.html, search.js, photo.js)
- The search box now holds removable filter chips instead of a single text
  field. Typing + Enter/Space (or picking a suggestion) commits the token as a
  chip; Backspace on an empty box removes the last chip; each chip has an × and
  a type-coloured icon (camera/date/lens/filter). Results update live as chips
  change, and the autocomplete dropdown is repositioned to sit under the
  variable-height box.
- New helpers photoParseTagToken / photoInputCommittable / month helpers turn a
  raw token into a friendly {label, value, type} chip.

Month searching (backend/imagedb.js, searchSuggest.js)
- Adds a calendar-month filter that matches across years via
  strftime('%m', taken_date). Supports bare month names/abbreviations ("june",
  "dec"), `month:6`, comma lists (`month:june,july`, OR-ed), and combining with
  a year ("june 2023"). Autocomplete now suggests months by name prefix.
- Fixes end-of-period date math so "2023-06" / "2023-02" bound their month
  correctly regardless of month length.

https://claude.ai/code/session_014XBV87rHxBy1BHwn89rgd8

* Photo search: stop autocomplete dropdown from flashing shut

The live/debounced search runs ~400ms after each keystroke and runSearch()
was closing the suggestions dropdown, so it appeared (~150ms after typing)
then vanished (~400ms) — looking like it disappeared right after showing.

- runSearch() no longer sets showSuggestions=false; the dropdown is now hidden
  only by explicit actions (picking/committing a tag, Escape, clear, or a click
  outside the search area).
- Moved click.outside from the dropdown to the search-input wrapper so clicking
  the input or a chip no longer dismisses the suggestions.

https://claude.ai/code/session_014XBV87rHxBy1BHwn89rgd8

* Photo search: OR within a category, AND across categories

Selecting several tags of the same kind now broadens the search instead of
contradicting itself. Previously every token was AND-ed, so e.g. "2025 2026"
asked for photos taken in both years at once and returned nothing.

The query builder now groups parsed tokens by category and OR-s the values
within each category, while different categories stay AND-ed:
  2025 2026 iso:1600  ->  (year 2025 OR year 2026) AND iso = 1600

- Each filter category (text, filename, camera model/make, lens, orientation,
  month, ISO/aperture/focal/MP/width/height ranges, taken/modified dates) is now
  a list; buildWhere emits an OR group per category and joins groups with AND.
  A single range token (e.g. iso:800-3200) is still one min..max condition.
- The front-end ignores exact duplicate chips.

Verified against a real SQLite engine: "2023 2024" returns both years' photos,
"canon nikon"/"iso:1600 iso:200" OR-match, while "canon 2023" and
"landscape 2024" still AND across categories.

https://claude.ai/code/session_014XBV87rHxBy1BHwn89rgd8

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 1 неделя назад
Родитель
Сommit
fff9b853cd

+ 1026 - 0
src/web/Photo/backend/imagedb.js

@@ -0,0 +1,1026 @@
+/*
+    imagedb.js
+
+    Photo index database helper library for the ArozOS Photo module.
+
+    This is a *shared* library (loaded via includes("imagedb.js")). It provides
+    everything the search / indexing backend scripts need:
+
+      - The per-user SQLite photo-index schema + open/migrate helper
+      - Photo metadata extraction (file name, resolution, dates, EXIF shooting
+        parameters) via the AGI imagelib / filelib libraries
+      - Incremental upsert / lookup helpers used by indexPhotos.js
+      - The iOS-style free-text query parser + parameterised SQL builder shared
+        by searchPhotos.js and searchSuggest.js
+      - Exclude-folder configuration (also consumed by exclude.js)
+
+    The index is stored per-user at INDEX_DB_PATH, so a user only ever sees their
+    own photos. The index is a derived cache of the file system: it can always be
+    rebuilt from scratch, which is why a schema-version bump simply drops & rebuilds.
+
+    NOTE: AGI scripts run on the Otto VM (ECMAScript 5.1). Keep this file ES5 —
+    no let/const, arrow functions, or template literals.
+
+    Requires (provided by this file): sqlite, filelib, imagelib
+*/
+
+requirelib("sqlite");
+requirelib("filelib");
+requirelib("imagelib");
+
+// Per-user SQLite index location. The sqlite lib creates parent dirs on open.
+var INDEX_DB_PATH = "user:/.appdata/photo/photoindex.db";
+
+// Bump when the schema below changes; openIndexDB() will rebuild the cache.
+var SCHEMA_VERSION = 1;
+
+// Image / RAW extension sets (kept in sync with constants.js + listFolder.js).
+var IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "webp", "gif", "arw", "cr2", "dng", "nef", "raf", "orf"];
+var RAW_EXTENSIONS = ["arw", "cr2", "dng", "nef", "raf", "orf"];
+
+/* ------------------------------------------------------------------ *
+ *  Small path helpers
+ * ------------------------------------------------------------------ */
+
+function db_getExt(filename) {
+    var parts = ("" + filename).split(".");
+    if (parts.length < 2) {
+        return "";
+    }
+    return parts.pop().toLowerCase();
+}
+
+function db_isImageFile(filename) {
+    return IMAGE_EXTENSIONS.indexOf(db_getExt(filename)) >= 0;
+}
+
+function db_isRawImage(filename) {
+    return RAW_EXTENSIONS.indexOf(db_getExt(filename)) >= 0;
+}
+
+function db_basename(filepath) {
+    return ("" + filepath).split("/").pop();
+}
+
+function db_dirname(filepath) {
+    var t = ("" + filepath).split("/");
+    t.pop();
+    return t.join("/");
+}
+
+/* ------------------------------------------------------------------ *
+ *  Schema + open/migrate
+ * ------------------------------------------------------------------ */
+
+function ensureSchema(db) {
+    db.exec(
+        "CREATE TABLE IF NOT EXISTS photos (" +
+        "id INTEGER PRIMARY KEY AUTOINCREMENT," +
+        "filepath TEXT UNIQUE NOT NULL," +   // virtual path, e.g. user:/Photo/a.jpg
+        "filename TEXT NOT NULL," +
+        "filename_lc TEXT NOT NULL," +       // lowercased name for case-insensitive search
+        "ext TEXT," +                        // lowercase extension without dot
+        "folder TEXT," +                     // parent folder virtual path
+        "filesize INTEGER," +                // bytes
+        "width INTEGER," +                   // pixels
+        "height INTEGER," +                  // pixels
+        "megapixels REAL," +                 // width*height / 1e6
+        "orientation TEXT," +                // landscape | portrait | square
+        "taken_date INTEGER," +              // unix sec, EXIF DateTimeOriginal (fallback mtime)
+        "modified_date INTEGER," +           // unix sec, file modification time
+        "camera_make TEXT," +
+        "camera_model TEXT," +
+        "lens_model TEXT," +
+        "focal_length REAL," +               // mm
+        "aperture REAL," +                   // f-number
+        "shutter REAL," +                    // exposure time in seconds
+        "shutter_label TEXT," +              // human readable, e.g. 1/250
+        "iso INTEGER," +
+        "has_exif INTEGER DEFAULT 0," +
+        "indexed_at INTEGER" +               // unix sec this row was (re)indexed
+        ")"
+    );
+
+    db.exec("CREATE INDEX IF NOT EXISTS idx_photos_filename_lc ON photos(filename_lc)");
+    db.exec("CREATE INDEX IF NOT EXISTS idx_photos_taken ON photos(taken_date)");
+    db.exec("CREATE INDEX IF NOT EXISTS idx_photos_modified ON photos(modified_date)");
+    db.exec("CREATE INDEX IF NOT EXISTS idx_photos_model ON photos(camera_model)");
+    db.exec("CREATE INDEX IF NOT EXISTS idx_photos_iso ON photos(iso)");
+    db.exec("CREATE INDEX IF NOT EXISTS idx_photos_ext ON photos(ext)");
+    db.exec("CREATE INDEX IF NOT EXISTS idx_photos_folder ON photos(folder)");
+
+    db.exec("CREATE TABLE IF NOT EXISTS index_meta (key TEXT PRIMARY KEY, value TEXT)");
+}
+
+function metaGet(db, key, fallback) {
+    var row = db.queryRow("SELECT value FROM index_meta WHERE key = ?", [key]);
+    if (row && row.value !== undefined && row.value !== null) {
+        return row.value;
+    }
+    return fallback;
+}
+
+function metaSet(db, key, value) {
+    db.exec(
+        "INSERT INTO index_meta (key, value) VALUES (?, ?) " +
+        "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+        [key, "" + value]
+    );
+}
+
+// Open (creating if needed), run migrations and return the connection (or null).
+// Returns null when the SQLite library is unavailable (e.g. the few build
+// targets without a modernc C-runtime port), so callers degrade gracefully
+// instead of throwing.
+function openIndexDB() {
+    if (typeof sqlite === "undefined" || !sqlite || typeof sqlite.open !== "function") {
+        return null;
+    }
+    var db = sqlite.open(INDEX_DB_PATH);
+    if (db == null) {
+        return null;
+    }
+    ensureSchema(db);
+
+    var current = parseInt(metaGet(db, "schema_version", "0")) || 0;
+    if (current !== SCHEMA_VERSION) {
+        // The index is a derived cache, so a forward bump simply rebuilds it.
+        if (current !== 0 && current < SCHEMA_VERSION) {
+            db.exec("DROP TABLE IF EXISTS photos");
+            ensureSchema(db);
+        }
+        metaSet(db, "schema_version", SCHEMA_VERSION);
+    }
+    return db;
+}
+
+/* ------------------------------------------------------------------ *
+ *  EXIF parsing helpers
+ *
+ *  imagelib.getExif() returns a map whose values are mostly JSON-encoded
+ *  strings, e.g. Make => "\"Canon\"" and FNumber => "\"28/10\"". We normalise
+ *  each value by attempting a JSON.parse, then interpret it as string/number.
+ *  This mirrors the proven parsing already used in photo.js.
+ * ------------------------------------------------------------------ */
+
+function exifRaw(exif, key) {
+    if (!exif || exif[key] === undefined || exif[key] === null) {
+        return undefined;
+    }
+    var v = exif[key];
+    if (typeof v === "string") {
+        try {
+            v = JSON.parse(v);
+        } catch (e) {
+            /* keep the raw string */
+        }
+    }
+    return v;
+}
+
+function exifFirst(v) {
+    if (Array.isArray(v)) {
+        return v.length ? v[0] : undefined;
+    }
+    return v;
+}
+
+// Parse an EXIF rational/number: "28/10" => 2.8, "100" => 100
+function exifToNumber(v) {
+    if (v === undefined || v === null) {
+        return null;
+    }
+    if (typeof v === "number") {
+        return v;
+    }
+    var s = ("" + v).trim();
+    if (s.indexOf("/") >= 0) {
+        var p = s.split("/");
+        if (p.length === 2) {
+            var num = parseFloat(p[0]);
+            var den = parseFloat(p[1]);
+            if (!isNaN(num) && !isNaN(den) && den !== 0) {
+                return num / den;
+            }
+        }
+    }
+    var n = parseFloat(s);
+    return isNaN(n) ? null : n;
+}
+
+function exifNumber(exif, key) {
+    return exifToNumber(exifFirst(exifRaw(exif, key)));
+}
+
+function exifString(exif, key) {
+    var v = exifFirst(exifRaw(exif, key));
+    if (v === undefined || v === null) {
+        return null;
+    }
+    var s = ("" + v).trim();
+    return s.length ? s : null;
+}
+
+function exifInt(exif, key) {
+    var n = exifNumber(exif, key);
+    return n === null ? null : Math.round(n);
+}
+
+// "2023:11:05 14:30:00" => unix seconds (interpreted as UTC for stable ranges).
+function exifDateToUnix(s) {
+    if (!s) {
+        return null;
+    }
+    var m = ("" + s).match(/^(\d{4})[:\-](\d{2})[:\-](\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
+    if (!m) {
+        return null;
+    }
+    var t = Date.UTC(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3]),
+        parseInt(m[4]), parseInt(m[5]), parseInt(m[6]));
+    if (isNaN(t)) {
+        return null;
+    }
+    return Math.floor(t / 1000);
+}
+
+function shutterLabel(seconds) {
+    if (seconds === null || seconds === undefined || seconds <= 0) {
+        return null;
+    }
+    if (seconds < 1) {
+        return "1/" + Math.round(1 / seconds);
+    }
+    return (Math.round(seconds * 10) / 10) + "s";
+}
+
+/* ------------------------------------------------------------------ *
+ *  Metadata extraction
+ * ------------------------------------------------------------------ */
+
+// Build the full metadata row for a single image file. modifiedUnix / filesize
+// can be supplied to avoid duplicate stat calls during a walk.
+function extractPhotoMeta(filepath, modifiedUnix, filesize) {
+    var filename = db_basename(filepath);
+    var ext = db_getExt(filename);
+
+    if (modifiedUnix === undefined || modifiedUnix === null) {
+        modifiedUnix = filelib.mtime(filepath, true);
+        if (modifiedUnix === false) {
+            modifiedUnix = null;
+        }
+    }
+    if (filesize === undefined || filesize === null) {
+        filesize = filelib.filesize(filepath);
+    }
+
+    var meta = {
+        filepath: filepath,
+        filename: filename,
+        filename_lc: filename.toLowerCase(),
+        ext: ext,
+        folder: db_dirname(filepath),
+        filesize: filesize || 0,
+        width: null,
+        height: null,
+        megapixels: null,
+        orientation: null,
+        taken_date: modifiedUnix || null,
+        modified_date: modifiedUnix || null,
+        camera_make: null,
+        camera_model: null,
+        lens_model: null,
+        focal_length: null,
+        aperture: null,
+        shutter: null,
+        shutter_label: null,
+        iso: null,
+        has_exif: 0,
+        indexed_at: Math.floor(Date.now() / 1000)
+    };
+
+    // Resolution (best effort; RAW may fail here and fall back to EXIF below).
+    try {
+        var dim = imagelib.getImageDimension(filepath);
+        if (dim && dim[0] && dim[1]) {
+            meta.width = dim[0];
+            meta.height = dim[1];
+        }
+    } catch (e) {
+        /* ignore — fall back to EXIF dimensions */
+    }
+
+    // EXIF: shooting parameters, taken date and possibly resolution.
+    var exif = null;
+    try {
+        if (imagelib.hasExif(filepath)) {
+            exif = JSON.parse(imagelib.getExif(filepath));
+        }
+    } catch (e) {
+        exif = null;
+    }
+
+    if (exif && typeof exif === "object") {
+        meta.has_exif = 1;
+
+        if (!meta.width || !meta.height) {
+            var w = exifInt(exif, "PixelXDimension");
+            var h = exifInt(exif, "PixelYDimension");
+            if (w && h) {
+                meta.width = w;
+                meta.height = h;
+            }
+        }
+
+        var taken = exifDateToUnix(exifString(exif, "DateTimeOriginal")) ||
+            exifDateToUnix(exifString(exif, "DateTimeDigitized")) ||
+            exifDateToUnix(exifString(exif, "DateTime"));
+        if (taken) {
+            meta.taken_date = taken;
+        }
+
+        meta.camera_make = exifString(exif, "Make");
+        meta.camera_model = exifString(exif, "Model");
+        meta.lens_model = exifString(exif, "LensModel");
+        meta.focal_length = exifNumber(exif, "FocalLength");
+        meta.aperture = exifNumber(exif, "FNumber");
+
+        var expTime = exifNumber(exif, "ExposureTime");
+        if (expTime !== null) {
+            meta.shutter = expTime;
+            meta.shutter_label = shutterLabel(expTime);
+        }
+        meta.iso = exifInt(exif, "ISOSpeedRatings");
+    }
+
+    // Derived geometry fields.
+    if (meta.width && meta.height) {
+        meta.megapixels = Math.round((meta.width * meta.height) / 1000000 * 10) / 10;
+        if (meta.width > meta.height) {
+            meta.orientation = "landscape";
+        } else if (meta.width < meta.height) {
+            meta.orientation = "portrait";
+        } else {
+            meta.orientation = "square";
+        }
+    }
+
+    return meta;
+}
+
+// Insert or update one photo row keyed by its (unique) virtual path.
+function upsertPhoto(db, m) {
+    db.exec(
+        "INSERT INTO photos (filepath, filename, filename_lc, ext, folder, filesize," +
+        " width, height, megapixels, orientation, taken_date, modified_date," +
+        " camera_make, camera_model, lens_model, focal_length, aperture, shutter," +
+        " shutter_label, iso, has_exif, indexed_at)" +
+        " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" +
+        " ON CONFLICT(filepath) DO UPDATE SET" +
+        " filename=excluded.filename, filename_lc=excluded.filename_lc, ext=excluded.ext," +
+        " folder=excluded.folder, filesize=excluded.filesize, width=excluded.width," +
+        " height=excluded.height, megapixels=excluded.megapixels, orientation=excluded.orientation," +
+        " taken_date=excluded.taken_date, modified_date=excluded.modified_date," +
+        " camera_make=excluded.camera_make, camera_model=excluded.camera_model," +
+        " lens_model=excluded.lens_model, focal_length=excluded.focal_length," +
+        " aperture=excluded.aperture, shutter=excluded.shutter, shutter_label=excluded.shutter_label," +
+        " iso=excluded.iso, has_exif=excluded.has_exif, indexed_at=excluded.indexed_at",
+        [m.filepath, m.filename, m.filename_lc, m.ext, m.folder, m.filesize,
+            m.width, m.height, m.megapixels, m.orientation, m.taken_date, m.modified_date,
+            m.camera_make, m.camera_model, m.lens_model, m.focal_length, m.aperture, m.shutter,
+            m.shutter_label, m.iso, m.has_exif, m.indexed_at]
+    );
+}
+
+/* ------------------------------------------------------------------ *
+ *  Photo roots + exclude folders
+ * ------------------------------------------------------------------ */
+
+// Photo library roots (mirrors backend/listRoots.js): every real (non-virtual)
+// storage that has a /Photo folder, plus the user's home Photo folder.
+function getPhotoRoots() {
+    var roots = [];
+    var seen = {};
+    for (var i = 0; i < USER_VROOTS.length; i++) {
+        var r = USER_VROOTS[i];
+        if (r.Filesystem === "virtual") {
+            continue;
+        }
+        var p = r.UUID + ":/Photo";
+        if (!seen[p] && filelib.fileExists(p)) {
+            roots.push(p);
+            seen[p] = true;
+        }
+    }
+    if (!seen["user:/Photo"] && filelib.fileExists("user:/Photo")) {
+        roots.push("user:/Photo");
+    }
+    return roots;
+}
+
+// Exclude list is stored as a JSON array string in index_meta. Each entry is a
+// path fragment; any file whose path contains "/<fragment>/" is skipped.
+function getExcludeFolders() {
+    var db = openIndexDB();
+    if (db == null) {
+        return "[]";
+    }
+    var raw = metaGet(db, "exclude_folders", "[]");
+    db.close();
+    return raw;
+}
+
+function setExcludeFolders(folders) {
+    var db = openIndexDB();
+    if (db == null) {
+        return;
+    }
+    var arr = folders;
+    if (typeof folders === "string") {
+        try {
+            arr = JSON.parse(folders);
+        } catch (e) {
+            arr = [];
+        }
+    }
+    if (!Array.isArray(arr)) {
+        arr = [];
+    }
+    metaSet(db, "exclude_folders", JSON.stringify(arr));
+    db.close();
+}
+
+function parseExcludeList(raw) {
+    try {
+        var arr = JSON.parse(raw);
+        if (Array.isArray(arr)) {
+            return arr;
+        }
+    } catch (e) {
+        /* ignore */
+    }
+    return [];
+}
+
+function isExcluded(filepath, excludeList) {
+    if (!excludeList || !excludeList.length) {
+        return false;
+    }
+    var p = "/" + filepath + "/";
+    for (var i = 0; i < excludeList.length; i++) {
+        var frag = ("" + excludeList[i]).replace(/^\/+|\/+$/g, "");
+        if (frag.length === 0) {
+            continue;
+        }
+        if (p.indexOf("/" + frag + "/") >= 0) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/* ------------------------------------------------------------------ *
+ *  Query parsing (iOS-style free text) + SQL builder
+ * ------------------------------------------------------------------ */
+
+// Numeric range token: ">800", "<1600", "800-3200", "800..3200", "100".
+function parseRange(s) {
+    s = ("" + s).trim();
+    var m;
+    if ((m = s.match(/^>=?\s*(.+)$/))) {
+        return { min: parseFloat(m[1]), max: null };
+    }
+    if ((m = s.match(/^<=?\s*(.+)$/))) {
+        return { min: null, max: parseFloat(m[1]) };
+    }
+    if ((m = s.match(/^(.+?)\.\.(.+)$/))) {
+        return { min: parseFloat(m[1]), max: parseFloat(m[2]) };
+    }
+    if ((m = s.match(/^([0-9.]+)-([0-9.]+)$/))) {
+        return { min: parseFloat(m[1]), max: parseFloat(m[2]) };
+    }
+    var v = parseFloat(s);
+    if (isNaN(v)) {
+        return null;
+    }
+    return { min: v, max: v };
+}
+
+// Date token => unix seconds. endOfDay pads to the *end* of the given period
+// (end of year / month / day) so "2023", "2023-06" and "2023-06-15" all bound
+// their period correctly regardless of how many days the month has.
+function parseDateToUnix(s, endOfDay) {
+    s = ("" + s).trim();
+    var m = s.match(/^(\d{4})(?:[\-\/](\d{1,2}))?(?:[\-\/](\d{1,2}))?$/);
+    if (!m) {
+        return null;
+    }
+    var y = parseInt(m[1]);
+    var hasMonth = m[2] !== undefined;
+    var hasDay = m[3] !== undefined;
+    var mo = hasMonth ? parseInt(m[2]) - 1 : 0;
+    var d = hasDay ? parseInt(m[3]) : 1;
+
+    if (!endOfDay) {
+        var t0 = Date.UTC(y, mo, d, 0, 0, 0);
+        return isNaN(t0) ? null : Math.floor(t0 / 1000);
+    }
+    // End of the specified period: start of the next period minus one second.
+    var t;
+    if (!hasMonth) {
+        t = Date.UTC(y + 1, 0, 1, 0, 0, 0) - 1000;        // end of year
+    } else if (!hasDay) {
+        t = Date.UTC(y, mo + 1, 1, 0, 0, 0) - 1000;       // end of month
+    } else {
+        t = Date.UTC(y, mo, d, 23, 59, 59);               // end of day
+    }
+    return isNaN(t) ? null : Math.floor(t / 1000);
+}
+
+function parseDateRange(s) {
+    s = ("" + s).trim();
+    var m;
+    if ((m = s.match(/^>=?\s*(.+)$/))) {
+        return { min: parseDateToUnix(m[1], false), max: null };
+    }
+    if ((m = s.match(/^<=?\s*(.+)$/))) {
+        return { min: null, max: parseDateToUnix(m[1], true) };
+    }
+    if ((m = s.match(/^(.+?)\.\.(.+)$/))) {
+        return { min: parseDateToUnix(m[1], false), max: parseDateToUnix(m[2], true) };
+    }
+    var single = parseDateToUnix(s, false);
+    if (single === null) {
+        return null;
+    }
+    return { min: single, max: parseDateToUnix(s, true) };
+}
+
+// Append one numeric range condition to a category list. Each token contributes
+// its own {min,max}; ranges within a category are OR-ed together at build time.
+function pushRange(filter, field, r) {
+    if (!r) {
+        return;
+    }
+    var hasMin = r.min !== null && r.min !== undefined && !isNaN(r.min);
+    var hasMax = r.max !== null && r.max !== undefined && !isNaN(r.max);
+    if (!hasMin && !hasMax) {
+        return;
+    }
+    if (!filter[field]) {
+        filter[field] = [];
+    }
+    filter[field].push({ min: hasMin ? r.min : null, max: hasMax ? r.max : null });
+}
+
+// Append one date range condition to a category list (OR-ed at build time).
+function pushDate(filter, field, r) {
+    if (!r) {
+        return;
+    }
+    var hasMin = r.min !== null && r.min !== undefined;
+    var hasMax = r.max !== null && r.max !== undefined;
+    if (!hasMin && !hasMax) {
+        return;
+    }
+    if (!filter[field]) {
+        filter[field] = [];
+    }
+    filter[field].push({ min: hasMin ? r.min : null, max: hasMax ? r.max : null });
+}
+
+var MONTH_NAMES = ["january", "february", "march", "april", "may", "june",
+    "july", "august", "september", "october", "november", "december"];
+var MONTH_ABBR = ["jan", "feb", "mar", "apr", "may", "jun",
+    "jul", "aug", "sep", "oct", "nov", "dec"];
+
+// Whether a bare word is a month name / abbreviation (not a number).
+function isMonthName(s) {
+    s = ("" + s).trim().toLowerCase();
+    return MONTH_NAMES.indexOf(s) >= 0 || MONTH_ABBR.indexOf(s) >= 0;
+}
+
+// Month name / abbreviation / number => 1..12, or null.
+function monthNameToNum(s) {
+    s = ("" + s).trim().toLowerCase();
+    var i = MONTH_NAMES.indexOf(s);
+    if (i >= 0) {
+        return i + 1;
+    }
+    i = MONTH_ABBR.indexOf(s);
+    if (i >= 0) {
+        return i + 1;
+    }
+    var n = parseInt(s);
+    if (!isNaN(n) && n >= 1 && n <= 12) {
+        return n;
+    }
+    return null;
+}
+
+// Month is a calendar-month filter (matches across all years). Stored as a list
+// so several months can be OR-ed together (handy with a tags input).
+function pushMonth(filter, mnum) {
+    if (!mnum || mnum < 1 || mnum > 12) {
+        return;
+    }
+    if (!filter.month) {
+        filter.month = [];
+    }
+    if (filter.month.indexOf(mnum) < 0) {
+        filter.month.push(mnum);
+    }
+}
+
+// Every category is a list. Within a category the values are OR-ed; across
+// categories they are AND-ed (see buildWhere). `raw` is the single boolean
+// exception (a shorthand that expands into the RAW extensions).
+function newFilter() {
+    return {
+        text: [], filename: [], ext: [], raw: false,
+        model: [], make: [], lens: [], orientation: [], month: [],
+        iso: [], aperture: [], focal: [], mp: [], width: [], height: [],
+        taken: [], modified: []
+    };
+}
+
+// Split a query string into tokens, honouring `key:"quoted value"` and "quoted".
+function tokenizeQuery(q) {
+    var tokens = [];
+    var re = /(\w+):"([^"]*)"|"([^"]*)"|(\S+)/g;
+    var m;
+    while ((m = re.exec(q)) !== null) {
+        if (m[1] !== undefined) {
+            tokens.push(m[1] + ":" + m[2]);
+        } else if (m[3] !== undefined) {
+            tokens.push(m[3]);
+        } else {
+            tokens.push(m[4]);
+        }
+    }
+    return tokens;
+}
+
+function classifyToken(filter, token) {
+    var lower = token.toLowerCase();
+    var colon = token.indexOf(":");
+    var key = "";
+    var val = "";
+    if (colon > 0) {
+        key = token.substring(0, colon).toLowerCase();
+        val = token.substring(colon + 1);
+    }
+
+    // f/2.8 or f2.8 (aperture shorthand)
+    var fm = lower.match(/^f\/?(\d+(?:\.\d+)?)$/);
+    if (colon < 0 && fm) {
+        pushRange(filter, "aperture", { min: parseFloat(fm[1]), max: parseFloat(fm[1]) });
+        return;
+    }
+
+    // 50mm (focal length shorthand)
+    var fmm = lower.match(/^(\d+(?:\.\d+)?)mm$/);
+    if (colon < 0 && fmm) {
+        pushRange(filter, "focal", { min: parseFloat(fmm[1]), max: parseFloat(fmm[1]) });
+        return;
+    }
+
+    if (colon < 0 && lower.charAt(0) === ".") {
+        filter.ext.push(lower.substring(1));
+        return;
+    }
+    if (colon < 0 && IMAGE_EXTENSIONS.indexOf(lower) >= 0) {
+        filter.ext.push(lower);
+        return;
+    }
+    if (colon < 0 && lower === "raw") {
+        filter.raw = true;
+        return;
+    }
+    if (colon < 0 && (lower === "landscape" || lower === "portrait" || lower === "square")) {
+        filter.orientation.push(lower);
+        return;
+    }
+    if (colon < 0 && isMonthName(lower)) {
+        pushMonth(filter, monthNameToNum(lower));
+        return;
+    }
+    if (colon < 0 && /^\d{4}$/.test(lower)) {
+        pushDate(filter, "taken", parseDateRange(lower));
+        return;
+    }
+
+    if (colon > 0) {
+        switch (key) {
+            case "iso":
+                pushRange(filter, "iso", parseRange(val));
+                return;
+            case "f":
+            case "aperture":
+            case "fnumber":
+                pushRange(filter, "aperture", parseRange(val.replace(/^\//, "")));
+                return;
+            case "focal":
+            case "fl":
+                pushRange(filter, "focal", parseRange(val.replace(/mm$/i, "")));
+                return;
+            case "mp":
+            case "megapixels":
+                pushRange(filter, "mp", parseRange(val));
+                return;
+            case "width":
+            case "w":
+                pushRange(filter, "width", parseRange(val));
+                return;
+            case "height":
+            case "h":
+                pushRange(filter, "height", parseRange(val));
+                return;
+            case "model":
+            case "camera":
+                filter.model.push(val);
+                return;
+            case "make":
+            case "brand":
+                filter.make.push(val);
+                return;
+            case "lens":
+                filter.lens.push(val);
+                return;
+            case "ext":
+            case "type":
+                filter.ext.push(val.toLowerCase().replace(/^\./, ""));
+                return;
+            case "name":
+            case "filename":
+                filter.filename.push(val);
+                return;
+            case "orientation":
+                filter.orientation.push(val.toLowerCase());
+                return;
+            case "month":
+                var monthVals = val.split(",");
+                for (var mvi = 0; mvi < monthVals.length; mvi++) {
+                    pushMonth(filter, monthNameToNum(monthVals[mvi]));
+                }
+                return;
+            case "taken":
+            case "date":
+            case "year":
+                pushDate(filter, "taken", parseDateRange(val));
+                return;
+            case "modified":
+            case "mtime":
+                pushDate(filter, "modified", parseDateRange(val));
+                return;
+            case "before":
+                pushDate(filter, "taken", { min: null, max: parseDateToUnix(val, true) });
+                return;
+            case "after":
+                pushDate(filter, "taken", { min: parseDateToUnix(val, false), max: null });
+                return;
+            default:
+                filter.text.push(token);
+                return;
+        }
+    }
+
+    // Plain free-text term.
+    filter.text.push(token);
+}
+
+// Parse a free-text query string into a structured filter object.
+function parseSearchQuery(q) {
+    var filter = newFilter();
+    if (!q) {
+        return filter;
+    }
+    var tokens = tokenizeQuery("" + q);
+    for (var i = 0; i < tokens.length; i++) {
+        classifyToken(filter, tokens[i]);
+    }
+    return filter;
+}
+
+// Merge an explicit structured filter object (from the UI) onto a parsed one.
+// String fields accept a single value or an array (OR-ed within the category).
+function applyExplicitFilters(filter, f) {
+    if (!f || typeof f !== "object") {
+        return;
+    }
+    function pushStrings(field, v) {
+        if (v === undefined || v === null) {
+            return;
+        }
+        var list = Array.isArray(v) ? v : [v];
+        for (var k = 0; k < list.length; k++) {
+            filter[field].push("" + list[k]);
+        }
+    }
+    pushStrings("filename", f.filename);
+    pushStrings("model", f.model);
+    pushStrings("make", f.make);
+    pushStrings("lens", f.lens);
+    if (f.orientation) {
+        var orients = Array.isArray(f.orientation) ? f.orientation : [f.orientation];
+        for (var o = 0; o < orients.length; o++) {
+            filter.orientation.push(("" + orients[o]).toLowerCase());
+        }
+    }
+    if (f.raw) {
+        filter.raw = true;
+    }
+    if (Array.isArray(f.ext)) {
+        for (var i = 0; i < f.ext.length; i++) {
+            filter.ext.push(("" + f.ext[i]).toLowerCase().replace(/^\./, ""));
+        }
+    }
+    var ranges = ["iso", "aperture", "focal", "mp", "width", "height"];
+    for (var r = 0; r < ranges.length; r++) {
+        var rv = f[ranges[r]];
+        if (rv) {
+            var rlist = Array.isArray(rv) ? rv : [rv];
+            for (var ri = 0; ri < rlist.length; ri++) {
+                pushRange(filter, ranges[r], rlist[ri]);
+            }
+        }
+    }
+    if (f.taken) {
+        var tlist = Array.isArray(f.taken) ? f.taken : [f.taken];
+        for (var ti = 0; ti < tlist.length; ti++) {
+            pushDate(filter, "taken", tlist[ti]);
+        }
+    }
+    if (f.modified) {
+        var mlist = Array.isArray(f.modified) ? f.modified : [f.modified];
+        for (var mi = 0; mi < mlist.length; mi++) {
+            pushDate(filter, "modified", mlist[mi]);
+        }
+    }
+    if (f.month) {
+        var fmonths = Array.isArray(f.month) ? f.month : [f.month];
+        for (var fmi = 0; fmi < fmonths.length; fmi++) {
+            pushMonth(filter, monthNameToNum(fmonths[fmi]));
+        }
+    }
+}
+
+// Wrap a list of OR-ed fragments as a single AND clause (parenthesised if >1).
+function pushOrGroup(clauses, fragments) {
+    if (!fragments.length) {
+        return;
+    }
+    clauses.push(fragments.length > 1 ? "(" + fragments.join(" OR ") + ")" : fragments[0]);
+}
+
+// OR group of LIKE conditions on a NOT NULL column (e.g. filename_lc).
+function addLikeGroup(clauses, args, col, list) {
+    if (!list || !list.length) {
+        return;
+    }
+    var ors = [];
+    for (var i = 0; i < list.length; i++) {
+        ors.push(col + " LIKE ?");
+        args.push("%" + ("" + list[i]).toLowerCase() + "%");
+    }
+    pushOrGroup(clauses, ors);
+}
+
+// OR group of LIKE conditions on a nullable column.
+function addNullableLikeGroup(clauses, args, col, list) {
+    if (!list || !list.length) {
+        return;
+    }
+    var ors = [];
+    for (var i = 0; i < list.length; i++) {
+        ors.push("LOWER(IFNULL(" + col + ",'')) LIKE ?");
+        args.push("%" + ("" + list[i]).toLowerCase() + "%");
+    }
+    pushOrGroup(clauses, ors);
+}
+
+// Equality OR group expressed as IN (...).
+function addInGroup(clauses, args, col, list) {
+    if (!list || !list.length) {
+        return;
+    }
+    var ph = [];
+    for (var i = 0; i < list.length; i++) {
+        ph.push("?");
+        args.push(list[i]);
+    }
+    clauses.push(col + " IN (" + ph.join(",") + ")");
+}
+
+// OR group of numeric/date ranges. Each {min,max} becomes
+// "(col >= min AND col <= max)"; the ranges within one category are OR-ed.
+function addRangeGroup(clauses, args, col, list) {
+    if (!list || !list.length) {
+        return;
+    }
+    var ors = [];
+    for (var i = 0; i < list.length; i++) {
+        var r = list[i];
+        if (!r) {
+            continue;
+        }
+        var parts = [];
+        if (r.min !== null && r.min !== undefined) {
+            parts.push(col + " >= ?");
+            args.push(r.min);
+        }
+        if (r.max !== null && r.max !== undefined) {
+            parts.push(col + " <= ?");
+            args.push(r.max);
+        }
+        if (!parts.length) {
+            continue;
+        }
+        ors.push(parts.length > 1 ? "(" + parts.join(" AND ") + ")" : parts[0]);
+    }
+    pushOrGroup(clauses, ors);
+}
+
+// Build the parameterised WHERE clause + args for a structured filter.
+// Within a category multiple values are OR-ed; categories are AND-ed together.
+function buildWhere(filter) {
+    var clauses = [];
+    var args = [];
+    var i;
+
+    // Free text: OR across terms, each term matching any of several columns.
+    if (filter.text && filter.text.length) {
+        var textOrs = [];
+        for (i = 0; i < filter.text.length; i++) {
+            var term = "%" + filter.text[i].toLowerCase() + "%";
+            textOrs.push("(filename_lc LIKE ? OR LOWER(IFNULL(camera_model,'')) LIKE ?" +
+                " OR LOWER(IFNULL(lens_model,'')) LIKE ? OR LOWER(IFNULL(camera_make,'')) LIKE ?)");
+            args.push(term, term, term, term);
+        }
+        pushOrGroup(clauses, textOrs);
+    }
+
+    addLikeGroup(clauses, args, "filename_lc", filter.filename);
+    addNullableLikeGroup(clauses, args, "camera_model", filter.model);
+    addNullableLikeGroup(clauses, args, "camera_make", filter.make);
+    addNullableLikeGroup(clauses, args, "lens_model", filter.lens);
+    addInGroup(clauses, args, "orientation", filter.orientation);
+
+    // Extensions (plus the RAW shorthand) are all OR-ed through a single IN.
+    var extList = (filter.ext || []).slice();
+    if (filter.raw) {
+        for (i = 0; i < RAW_EXTENSIONS.length; i++) {
+            if (extList.indexOf(RAW_EXTENSIONS[i]) < 0) {
+                extList.push(RAW_EXTENSIONS[i]);
+            }
+        }
+    }
+    addInGroup(clauses, args, "ext", extList);
+
+    // Calendar months (matched across all years), OR-ed via IN.
+    if (filter.month && filter.month.length) {
+        var mph = [];
+        for (i = 0; i < filter.month.length; i++) {
+            mph.push("?");
+            args.push(filter.month[i]);
+        }
+        clauses.push("CAST(strftime('%m', taken_date, 'unixepoch') AS INTEGER) IN (" + mph.join(",") + ")");
+    }
+
+    addRangeGroup(clauses, args, "iso", filter.iso);
+    addRangeGroup(clauses, args, "aperture", filter.aperture);
+    addRangeGroup(clauses, args, "focal_length", filter.focal);
+    addRangeGroup(clauses, args, "megapixels", filter.mp);
+    addRangeGroup(clauses, args, "width", filter.width);
+    addRangeGroup(clauses, args, "height", filter.height);
+    addRangeGroup(clauses, args, "taken_date", filter.taken);
+    addRangeGroup(clauses, args, "modified_date", filter.modified);
+
+    return { clause: clauses.length ? clauses.join(" AND ") : "1=1", args: args };
+}
+
+function buildOrderBy(sort) {
+    switch (sort) {
+        case "taken_asc":
+            return "taken_date ASC";
+        case "taken_desc":
+            return "taken_date DESC";
+        case "modified_asc":
+            return "modified_date ASC";
+        case "modified_desc":
+            return "modified_date DESC";
+        case "name_asc":
+            return "filename_lc ASC";
+        case "name_desc":
+            return "filename_lc DESC";
+        case "size_asc":
+            return "filesize ASC";
+        case "size_desc":
+            return "filesize DESC";
+        case "mp_desc":
+            return "megapixels DESC";
+        case "mp_asc":
+            return "megapixels ASC";
+        default:
+            return "taken_date DESC";
+    }
+}

+ 160 - 0
src/web/Photo/backend/indexPhotos.js

@@ -0,0 +1,160 @@
+/*
+    indexPhotos.js
+
+    Incremental photo indexer for the Photo module search feature.
+
+    Walks the user's photo library roots (or a single `root` if given), extracts
+    metadata for every image and stores it in the per-user SQLite index. Designed
+    to be called repeatedly: each request fully processes at most BATCH new/changed
+    files and reports `hasMore`, so the front-end can loop without blocking on a
+    huge library. This is what powers "auto indexing" — the Photo app kicks this
+    off in the background on load and loops until `hasMore` is false.
+
+    Request body (JSON, all optional):
+      {
+        "mode": "incremental" | "full",   // full wipes the index first, then
+                                          //   indexes incrementally (see below)
+        "root": "user:/Photo"             // restrict to one root (no deletes)
+      }
+
+    "full" is a one-shot reset: it clears the index at the start of the call and
+    then proceeds exactly like an incremental pass. The caller should send "full"
+    once and then keep looping with "incremental" until hasMore is false — that
+    way each round makes forward progress instead of re-extracting the same files.
+
+    Response (JSON):
+      { scanned, indexed, removed, remaining, hasMore, total }
+
+    Called with no body it indexes all photo roots incrementally, which also makes
+    it safe to wire up as a background / nightly task in future.
+*/
+
+includes("imagedb.js");
+
+// Images fully extracted (resolution + EXIF) per request. Bounds request time;
+// the expensive work is gated by this, the cheap directory walk is not.
+var BATCH = 30;
+
+function main() {
+    var rawBody = (typeof POST_data !== "undefined") ? POST_data : "{}";
+    var payload = {};
+    try {
+        payload = JSON.parse(rawBody) || {};
+    } catch (e) {
+        payload = {};
+    }
+
+    var mode = payload.mode || "incremental";   // incremental | full
+    var rootParam = payload.root || null;
+
+    var db = openIndexDB();
+    if (db == null) {
+        sendJSONResp(JSON.stringify({ error: "index unavailable", hasMore: false }));
+        return;
+    }
+
+    var excludeList = parseExcludeList(metaGet(db, "exclude_folders", "[]"));
+    var roots = rootParam ? [rootParam] : getPhotoRoots();
+
+    // Full rebuild is a one-shot reset: wipe the index, then index incrementally
+    // so each subsequent (incremental) round keeps making forward progress.
+    if (mode === "full") {
+        if (rootParam) {
+            db.exec("DELETE FROM photos WHERE folder = ? OR folder LIKE ?", [rootParam, rootParam + "/%"]);
+        } else {
+            db.exec("DELETE FROM photos");
+        }
+    }
+
+    // Snapshot the current index for cheap incremental comparison.
+    var existing = {};
+    var existingRows = db.query("SELECT filepath, modified_date, filesize FROM photos");
+    for (var i = 0; i < existingRows.length; i++) {
+        existing[existingRows[i].filepath] = existingRows[i];
+    }
+
+    // Walk every root and decide which files need (re)indexing.
+    var present = {};
+    var pending = [];
+    var scanned = 0;
+    for (var r = 0; r < roots.length; r++) {
+        var files;
+        try {
+            files = filelib.walk(roots[r], "file");
+        } catch (e) {
+            files = [];
+        }
+        if (!files) {
+            files = [];
+        }
+        for (var f = 0; f < files.length; f++) {
+            var fp = files[f];
+            if (!db_isImageFile(fp)) {
+                continue;
+            }
+            if (isExcluded(fp, excludeList)) {
+                continue;
+            }
+            present[fp] = true;
+            scanned++;
+
+            var mt = filelib.mtime(fp, true);
+            if (mt === false) {
+                mt = 0;
+            }
+            var sz = filelib.filesize(fp);
+            var ex = existing[fp];
+            // After a full-mode wipe the index is empty, so every file is "new"
+            // here and gets queued; on later incremental rounds matched files are
+            // skipped, which is what lets the batch loop advance to completion.
+            if (!ex || ex.modified_date != mt || ex.filesize != sz) {
+                pending.push({ filepath: fp, mtime: mt, filesize: sz });
+            }
+        }
+    }
+
+    // Prune deleted files — only when scanning the whole library (no single root),
+    // otherwise we'd wrongly drop photos that live outside the requested root.
+    var removed = 0;
+    if (!rootParam) {
+        for (var key in existing) {
+            if (!present[key]) {
+                db.exec("DELETE FROM photos WHERE filepath = ?", [key]);
+                removed++;
+            }
+        }
+    }
+
+    // Process at most BATCH pending files this round.
+    var indexed = 0;
+    var processCount = Math.min(BATCH, pending.length);
+    for (var p = 0; p < processCount; p++) {
+        var item = pending[p];
+        try {
+            var meta = extractPhotoMeta(item.filepath, item.mtime, item.filesize);
+            upsertPhoto(db, meta);
+            indexed++;
+        } catch (e) {
+            /* unreadable / unsupported file — skip it */
+        }
+    }
+
+    var remaining = pending.length - processCount;
+    var totalRow = db.queryRow("SELECT COUNT(*) AS c FROM photos");
+    var total = totalRow ? totalRow.c : 0;
+
+    metaSet(db, "last_indexed", Math.floor(Date.now() / 1000));
+    metaSet(db, "last_scan_count", scanned);
+    db.close();
+
+    sendJSONResp(JSON.stringify({
+        scanned: scanned,
+        indexed: indexed,
+        removed: removed,
+        remaining: remaining,
+        hasMore: remaining > 0,
+        total: total
+    }));
+}
+
+main();

+ 58 - 0
src/web/Photo/backend/indexStatus.js

@@ -0,0 +1,58 @@
+/*
+    indexStatus.js
+
+    Lightweight status report for the photo search index. The front-end uses this
+    to show how many photos are indexed and to decide whether a background
+    (auto) index pass is worth kicking off.
+
+    Response (JSON):
+      {
+        available,       // false if the SQLite index could not be opened
+        total,           // indexed photo count
+        withExif,        // how many carry EXIF
+        cameras,         // distinct camera models
+        dateMin,         // earliest taken_date (unix sec) or null
+        dateMax,         // latest taken_date (unix sec) or null
+        lastIndexed,     // unix sec of the last index pass
+        schemaVersion
+      }
+*/
+
+includes("imagedb.js");
+
+function main() {
+    var db = openIndexDB();
+    if (db == null) {
+        sendJSONResp(JSON.stringify({ available: false }));
+        return;
+    }
+
+    var totalRow = db.queryRow("SELECT COUNT(*) AS c FROM photos");
+    var total = totalRow ? totalRow.c : 0;
+
+    var exifRow = db.queryRow("SELECT COUNT(*) AS c FROM photos WHERE has_exif = 1");
+    var withExif = exifRow ? exifRow.c : 0;
+
+    var camRow = db.queryRow("SELECT COUNT(DISTINCT camera_model) AS c FROM photos" +
+        " WHERE camera_model IS NOT NULL AND camera_model <> ''");
+    var cameras = camRow ? camRow.c : 0;
+
+    var range = db.queryRow("SELECT MIN(taken_date) AS mn, MAX(taken_date) AS mx" +
+        " FROM photos WHERE taken_date IS NOT NULL") || {};
+
+    var lastIndexed = parseInt(metaGet(db, "last_indexed", "0")) || 0;
+    db.close();
+
+    sendJSONResp(JSON.stringify({
+        available: true,
+        total: total,
+        withExif: withExif,
+        cameras: cameras,
+        dateMin: range.mn || null,
+        dateMax: range.mx || null,
+        lastIndexed: lastIndexed,
+        schemaVersion: SCHEMA_VERSION
+    }));
+}
+
+main();

+ 104 - 0
src/web/Photo/backend/searchPhotos.js

@@ -0,0 +1,104 @@
+/*
+    searchPhotos.js
+
+    Search the per-user photo index. Accepts an iOS-style free-text query and/or
+    a structured filter object, and returns matching photos. Search covers — but
+    is not limited to — file name, resolution, shooting parameters (camera, lens,
+    ISO, aperture, shutter, focal length), the date a photo was taken (created)
+    and its file modification date.
+
+    Request body (JSON, all optional):
+      {
+        "q": "canon iso:1600 f/2.8 2023 landscape",  // free-text query
+        "filters": { ... },                          // structured overrides
+        "sort": "taken_desc",                        // see buildOrderBy()
+        "limit": 1000,
+        "offset": 0
+      }
+
+    Response (JSON):
+      { results: [ {filepath, filesize, ...} ], total, limit, offset, query }
+
+    Each result carries `filepath` and `filesize`, so the existing Photo grid /
+    viewer renders them with no further changes.
+
+    Free-text query tokens (combine freely, all AND-ed together):
+      canon              free text -> file name / camera / lens / make
+      name:beach         file name contains
+      model:"EOS R5"     camera model
+      make:sony          camera make
+      lens:50            lens model contains
+      iso:1600           ISO equals (or 800-3200, >800, <1600)
+      f/2.8  aperture:2  aperture (or 1.4-2.8)
+      focal:50  50mm     focal length mm (or 24-70)
+      mp:24  mp:>20      megapixels
+      width:>4000        pixel width   (also w:)
+      height:>3000       pixel height  (also h:)
+      landscape          orientation (portrait / square)
+      .jpg  raw          file type / RAW group
+      2023  year:2023    taken in year
+      taken:2023-01..2023-06   taken date range  (before:/after: also work)
+      modified:>2024-01-01     file modified date
+*/
+
+includes("imagedb.js");
+
+function main() {
+    var rawBody = (typeof POST_data !== "undefined") ? POST_data : "{}";
+    var payload = {};
+    try {
+        payload = JSON.parse(rawBody) || {};
+    } catch (e) {
+        payload = {};
+    }
+
+    var q = payload.q || "";
+    var filters = payload.filters || {};
+    var sort = payload.sort || "taken_desc";
+
+    var limit = parseInt(payload.limit);
+    if (isNaN(limit) || limit <= 0) {
+        limit = 500;
+    }
+    if (limit > 2000) {
+        limit = 2000;
+    }
+    var offset = parseInt(payload.offset);
+    if (isNaN(offset) || offset < 0) {
+        offset = 0;
+    }
+
+    var db = openIndexDB();
+    if (db == null) {
+        sendJSONResp(JSON.stringify({ error: "index unavailable", results: [], total: 0 }));
+        return;
+    }
+
+    var filter = parseSearchQuery(q);
+    applyExplicitFilters(filter, filters);
+
+    var w = buildWhere(filter);
+    var orderBy = buildOrderBy(sort);
+
+    var countRow = db.queryRow("SELECT COUNT(*) AS c FROM photos WHERE " + w.clause, w.args);
+    var total = countRow ? countRow.c : 0;
+
+    var rows = db.query(
+        "SELECT filepath, filename, filesize, ext, width, height, megapixels, orientation," +
+        " taken_date, modified_date, camera_make, camera_model, lens_model, focal_length," +
+        " aperture, shutter, shutter_label, iso, has_exif FROM photos WHERE " + w.clause +
+        " ORDER BY " + orderBy + " LIMIT ? OFFSET ?",
+        w.args.concat([limit, offset])
+    );
+    db.close();
+
+    sendJSONResp(JSON.stringify({
+        results: rows,
+        total: total,
+        limit: limit,
+        offset: offset,
+        query: q
+    }));
+}
+
+main();

+ 180 - 0
src/web/Photo/backend/searchSuggest.js

@@ -0,0 +1,180 @@
+/*
+    searchSuggest.js
+
+    Autocomplete suggestions for the Photo search box (iOS-style). Given the
+    partial query the user is typing, returns a small, ranked, de-duplicated list
+    of suggestions drawn from the actual contents of the per-user index:
+
+      - When the box is empty: popular facets (top cameras, recent years,
+        orientation, common file types) so there is always something to tap.
+      - While typing: completions for the active (last) token — matching camera
+        models, lenses, file names, years, file types, plus smart filter tokens
+        (iso:..., f/...) when the user starts typing a known filter keyword.
+
+    Each suggestion: { type, label, value, hint }
+      type  -> drives the icon on the front-end (camera/lens/date/type/file/filter)
+      label -> what is shown to the user
+      value -> text inserted into the query box when picked
+      hint  -> small right-aligned note (usually a photo count)
+
+    Request body (JSON): { "q": "partial query" }
+    Response (JSON):      { suggestions: [...], query }
+*/
+
+includes("imagedb.js");
+
+function buildSuggestions(db, q) {
+    var out = [];
+    var lower = ("" + q).toLowerCase();
+
+    function add(type, label, value, hint) {
+        if (label === null || label === undefined || ("" + label).length === 0) {
+            return;
+        }
+        out.push({ type: type, label: "" + label, value: "" + value, hint: hint || "" });
+    }
+
+    // ---- Empty box: show popular facets -------------------------------------
+    if (lower.length === 0) {
+        var cams = db.query("SELECT camera_model AS m, COUNT(*) AS c FROM photos" +
+            " WHERE camera_model IS NOT NULL AND camera_model <> ''" +
+            " GROUP BY camera_model ORDER BY c DESC LIMIT 4");
+        for (var i = 0; i < cams.length; i++) {
+            add("camera", cams[i].m, "model:\"" + cams[i].m + "\"", cams[i].c + " photos");
+        }
+
+        var yrs = db.query("SELECT strftime('%Y', taken_date, 'unixepoch') AS y, COUNT(*) AS c" +
+            " FROM photos WHERE taken_date IS NOT NULL GROUP BY y ORDER BY y DESC LIMIT 4");
+        for (var j = 0; j < yrs.length; j++) {
+            add("date", yrs[j].y, yrs[j].y, yrs[j].c + " photos");
+        }
+
+        add("filter", "Landscape", "landscape", "orientation");
+        add("filter", "Portrait", "portrait", "orientation");
+
+        var exts = db.query("SELECT ext, COUNT(*) AS c FROM photos" +
+            " WHERE ext IS NOT NULL AND ext <> '' GROUP BY ext ORDER BY c DESC LIMIT 3");
+        for (var k = 0; k < exts.length; k++) {
+            add("type", "." + exts[k].ext, "." + exts[k].ext, exts[k].c + " photos");
+        }
+        return out;
+    }
+
+    // ---- Typing: complete the active (last) token ---------------------------
+    var parts = ("" + q).split(/\s+/);
+    var lastToken = parts[parts.length - 1].toLowerCase();
+    var prefix = parts.slice(0, parts.length - 1).join(" ");
+
+    function withPrefix(tok) {
+        return (prefix ? prefix + " " : "") + tok;
+    }
+
+    var like = "%" + lastToken + "%";
+    var a, list;
+
+    // Smart filter-keyword completions.
+    if (lastToken.indexOf("iso") === 0) {
+        list = db.query("SELECT DISTINCT iso FROM photos WHERE iso IS NOT NULL ORDER BY iso LIMIT 6");
+        for (a = 0; a < list.length; a++) {
+            add("filter", "ISO " + list[a].iso, withPrefix("iso:" + list[a].iso), "");
+        }
+    }
+    if (/^f\/?\d*\.?\d*$/.test(lastToken)) {
+        list = db.query("SELECT DISTINCT aperture FROM photos WHERE aperture IS NOT NULL ORDER BY aperture LIMIT 6");
+        for (a = 0; a < list.length; a++) {
+            var av = Math.round(list[a].aperture * 10) / 10;
+            add("filter", "f/" + av, withPrefix("f/" + av), "");
+        }
+    }
+
+    // Camera models.
+    list = db.query("SELECT camera_model AS m, COUNT(*) AS c FROM photos" +
+        " WHERE LOWER(IFNULL(camera_model,'')) LIKE ?" +
+        " GROUP BY camera_model ORDER BY c DESC LIMIT 4", [like]);
+    for (a = 0; a < list.length; a++) {
+        add("camera", list[a].m, withPrefix("model:\"" + list[a].m + "\""), list[a].c + " photos");
+    }
+
+    // Lenses.
+    list = db.query("SELECT lens_model AS l, COUNT(*) AS c FROM photos" +
+        " WHERE LOWER(IFNULL(lens_model,'')) LIKE ?" +
+        " GROUP BY lens_model ORDER BY c DESC LIMIT 3", [like]);
+    for (a = 0; a < list.length; a++) {
+        add("lens", list[a].l, withPrefix("lens:\"" + list[a].l + "\""), list[a].c + " photos");
+    }
+
+    // File names.
+    list = db.query("SELECT filename FROM photos WHERE filename_lc LIKE ?" +
+        " ORDER BY filename_lc LIMIT 6", [like]);
+    for (a = 0; a < list.length; a++) {
+        add("file", list[a].filename, list[a].filename, "file name");
+    }
+
+    // Years.
+    if (/^\d{1,4}$/.test(lastToken)) {
+        list = db.query("SELECT strftime('%Y', taken_date, 'unixepoch') AS y, COUNT(*) AS c" +
+            " FROM photos WHERE taken_date IS NOT NULL GROUP BY y HAVING y LIKE ?" +
+            " ORDER BY y DESC LIMIT 4", [lastToken + "%"]);
+        for (a = 0; a < list.length; a++) {
+            add("date", list[a].y, withPrefix(list[a].y), list[a].c + " photos");
+        }
+    }
+
+    // Months (match by name prefix; the filter matches that month across all years).
+    if (/^[a-z]+$/.test(lastToken)) {
+        var MN = ["January", "February", "March", "April", "May", "June",
+            "July", "August", "September", "October", "November", "December"];
+        for (a = 0; a < MN.length; a++) {
+            if (MN[a].toLowerCase().indexOf(lastToken) === 0) {
+                var mc = db.queryRow("SELECT COUNT(*) AS c FROM photos WHERE taken_date IS NOT NULL" +
+                    " AND CAST(strftime('%m', taken_date, 'unixepoch') AS INTEGER) = ?", [a + 1]);
+                add("date", MN[a], withPrefix("month:" + (a + 1)), (mc && mc.c ? mc.c + " photos" : ""));
+            }
+        }
+    }
+
+    // File types.
+    if (lastToken.charAt(0) === "." || lastToken.length <= 4) {
+        var ex = lastToken.replace(/^\./, "");
+        list = db.query("SELECT ext, COUNT(*) AS c FROM photos WHERE ext LIKE ?" +
+            " GROUP BY ext ORDER BY c DESC LIMIT 3", [ex + "%"]);
+        for (a = 0; a < list.length; a++) {
+            add("type", "." + list[a].ext, withPrefix("." + list[a].ext), list[a].c + " photos");
+        }
+    }
+
+    // De-duplicate by inserted value and cap the list length.
+    var seen = {};
+    var dedup = [];
+    for (var d = 0; d < out.length; d++) {
+        if (!seen[out[d].value]) {
+            seen[out[d].value] = true;
+            dedup.push(out[d]);
+        }
+    }
+    return dedup.slice(0, 12);
+}
+
+function main() {
+    var rawBody = (typeof POST_data !== "undefined") ? POST_data : "{}";
+    var payload = {};
+    try {
+        payload = JSON.parse(rawBody) || {};
+    } catch (e) {
+        payload = {};
+    }
+    var q = payload.q || "";
+
+    var db = openIndexDB();
+    if (db == null) {
+        sendJSONResp(JSON.stringify({ suggestions: [], query: q }));
+        return;
+    }
+
+    var suggestions = buildSuggestions(db, q);
+    db.close();
+
+    sendJSONResp(JSON.stringify({ suggestions: suggestions, query: q }));
+}
+
+main();

+ 207 - 3
src/web/Photo/index.html

@@ -15,6 +15,7 @@
     <script src="../script/ao_module.js"></script>
     <script src="constants.js"></script>
     <script src="histogram.js"></script>
+    <script src="search.js"></script>
     <script src="photo.js"></script>
     <style>
         #main {
@@ -210,6 +211,8 @@
             overflow: hidden;
             background: #1a1a1a;
             min-width: 0;
+            display: flex;
+            flex-direction: column;
         }
         
         body {
@@ -238,11 +241,156 @@
         }
 
         #viewboxContainer{
-            height: 100%;
+            flex: 1;
+            min-height: 0;
             overflow-y: auto;
             overflow-x: hidden;
         }
 
+        /* ── Search bar ──────────────────────────────────────────────────── */
+        #search-header {
+            position: relative;
+            padding: 0.6em 0.8em 0.45em 0.8em;
+            background: #1a1a1a;
+            border-bottom: 1px solid #2a2a2a;
+            flex-shrink: 0;
+        }
+        .search-input-wrap { position: relative; }
+        #search-box {
+            display: flex;
+            align-items: center;
+            background: #252525;
+            border: 1px solid #3a3a3a;
+            border-radius: 8px;
+            padding: 0.4em 0.7em;
+            transition: border-color 0.15s;
+            cursor: text;
+        }
+        #search-box.focused,
+        #search-box:focus-within {
+            border-color: #f76c5d;
+        }
+        #search-box > i.search.icon { color: #888; margin-right: 0.5em; flex-shrink: 0; align-self: center; }
+
+        /* Tags / chips */
+        .search-tags {
+            flex: 1;
+            display: flex;
+            flex-wrap: wrap;
+            align-items: center;
+            gap: 0.3em;
+            min-width: 0;
+        }
+        .search-tag {
+            display: inline-flex;
+            align-items: center;
+            gap: 0.3em;
+            background: rgba(247, 108, 93, 0.16);
+            border: 1px solid rgba(247, 108, 93, 0.4);
+            color: #f7b3aa;
+            border-radius: 5px;
+            padding: 0.1em 0.2em 0.1em 0.45em;
+            font-size: 0.82em;
+            line-height: 1.6;
+            white-space: nowrap;
+            max-width: 100%;
+        }
+        .search-tag > span { overflow: hidden; text-overflow: ellipsis; }
+        .search-tag > i.icon { margin: 0; font-size: 0.85em; color: #f76c5d; flex-shrink: 0; }
+        .search-tag .tag-remove {
+            cursor: pointer;
+            color: #f7b3aa;
+            opacity: 0.7;
+            margin: 0 0.05em;
+            font-size: 0.8em;
+        }
+        .search-tag .tag-remove:hover { opacity: 1; color: #fff; }
+        /* Per-type chip accents */
+        .search-tag.tag-camera { background: rgba(86, 156, 214, 0.16); border-color: rgba(86, 156, 214, 0.4); color: #9ccbf0; }
+        .search-tag.tag-camera > i.icon, .search-tag.tag-camera .tag-remove { color: #569cd6; }
+        .search-tag.tag-date { background: rgba(201, 162, 39, 0.18); border-color: rgba(201, 162, 39, 0.45); color: #e0c068; }
+        .search-tag.tag-date > i.icon, .search-tag.tag-date .tag-remove { color: #c9a227; }
+        .search-tag.tag-lens { background: rgba(137, 200, 100, 0.16); border-color: rgba(137, 200, 100, 0.4); color: #b6dd9a; }
+        .search-tag.tag-lens > i.icon, .search-tag.tag-lens .tag-remove { color: #89c864; }
+
+        #search-box .search-tags input {
+            flex: 1 1 90px;
+            background: transparent;
+            border: none;
+            outline: none;
+            color: #eee;
+            font-size: 0.95em;
+            min-width: 90px;
+            padding: 0.18em 0;
+        }
+        #search-box .search-tags input::placeholder { color: #666; }
+        .search-clear {
+            color: #888;
+            cursor: pointer;
+            margin-left: 0.5em;
+            flex-shrink: 0;
+            align-self: center;
+        }
+        .search-clear:hover { color: #f76c5d; }
+
+        #search-suggestions {
+            position: absolute;
+            left: 0;
+            right: 0;
+            top: calc(100% + 4px);
+            background: #222;
+            border: 1px solid #3a3a3a;
+            border-radius: 8px;
+            z-index: 30;
+            max-height: 340px;
+            overflow-y: auto;
+            box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
+        }
+        .suggest-item {
+            display: flex;
+            align-items: center;
+            padding: 0.55em 0.85em;
+            cursor: pointer;
+            color: #ccc;
+            font-size: 0.9em;
+        }
+        .suggest-item:hover,
+        .suggest-item.active { background: #2d2d2d; color: #fff; }
+        .suggest-item > i.icon { width: 1.4em; color: #f76c5d; flex-shrink: 0; }
+        .suggest-label {
+            flex: 1;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+        }
+        .suggest-hint {
+            color: #777;
+            font-size: 0.8em;
+            margin-left: 0.6em;
+            flex-shrink: 0;
+        }
+
+        #search-meta {
+            display: flex;
+            align-items: center;
+            gap: 0.9em;
+            padding: 0.5em 0.3em 0.1em 0.3em;
+            color: #aaa;
+            font-size: 0.82em;
+        }
+        .search-clear-link { color: #f76c5d; cursor: pointer; }
+        .search-clear-link:hover { text-decoration: underline; }
+        #index-chip {
+            margin-left: auto;
+            color: #888;
+            font-size: 0.95em;
+            display: flex;
+            align-items: center;
+            gap: 0.35em;
+        }
+        #index-chip .reindex-link { color: #888; cursor: pointer; }
+        #index-chip .reindex-link:hover { color: #f76c5d; }
+
         #fadeplate{
             position: absolute;
             top: 0px;
@@ -760,11 +908,67 @@
             </div><!-- /#sidebar -->
 
             <div id="display">
+                <!-- Search bar (iOS-style tags input with autocomplete) -->
+                <div id="search-header">
+                    <div class="search-input-wrap" x-on:click.outside="showSuggestions = false">
+                        <div id="search-box" :class="{ 'focused': showSuggestions }"
+                            x-on:click="$refs.searchInput.focus()">
+                            <i class="search icon"></i>
+                            <div class="search-tags">
+                                <template x-for="(tag, ti) in searchTags" :key="ti">
+                                    <span class="search-tag" :class="'tag-' + tag.type">
+                                        <i class="icon" :class="suggestIcon(tag.type)"></i>
+                                        <span x-text="tag.label"></span>
+                                        <i class="times icon tag-remove" x-on:click.stop="removeTag(ti)"></i>
+                                    </span>
+                                </template>
+                                <input type="text" id="photo-search-input" x-ref="searchInput"
+                                    autocomplete="off" spellcheck="false"
+                                    :placeholder="searchTags.length ? '' : 'Search — name, camera, ISO, f/2.8, June, 2023, landscape…'"
+                                    x-model="searchInput"
+                                    x-on:input="onSearchInput()"
+                                    x-on:keydown="onSearchKeydown($event)"
+                                    x-on:focus="onSearchInput()">
+                            </div>
+                            <i class="times circle icon search-clear"
+                                x-show="searchTags.length > 0 || searchInput.length > 0"
+                                x-on:click.stop="clearSearch()"></i>
+                        </div>
+
+                        <!-- Autocomplete dropdown -->
+                        <div id="search-suggestions" x-show="showSuggestions">
+                            <template x-for="(s, i) in suggestions" :key="i">
+                                <div class="suggest-item" :class="{ 'active': i === suggestIndex }"
+                                    x-on:mousedown.prevent="applySuggestion(s)"
+                                    x-on:mouseenter="suggestIndex = i">
+                                    <i class="icon" :class="suggestIcon(s.type)"></i>
+                                    <span class="suggest-label" x-text="s.label"></span>
+                                    <span class="suggest-hint" x-text="s.hint"></span>
+                                </div>
+                            </template>
+                        </div>
+                    </div>
+
+                    <!-- Result count / indexing status -->
+                    <div id="search-meta" x-show="searchMode || indexStatusText.length > 0">
+                        <span x-show="searchMode"
+                            x-text="searchTotal + ' result' + (searchTotal === 1 ? '' : 's')"></span>
+                        <a class="search-clear-link" x-show="searchMode" x-on:click="clearSearch()">Clear search</a>
+                        <span id="index-chip" x-show="indexStatusText.length > 0">
+                            <i class="loading spinner icon" x-show="indexing"></i>
+                            <span x-text="indexStatusText"></span>
+                            <a class="reindex-link" x-show="!indexing" x-on:click="rebuildIndex()"
+                                title="Rebuild the search index">Rebuild</a>
+                        </span>
+                    </div>
+                </div>
+
                 <div id="noimg" class="ui basic inverted segment" style="display:none;">
                     <h4 class="ui header">
                         <div class="content">
-                            Empty Folder
-                            <div class="sub header">There are no photo stored in <span x-text="currentPath + '/'"></span></div>
+                            <span x-text="searchMode ? 'No matching photos' : 'Empty Folder'">Empty Folder</span>
+                            <div class="sub header" x-show="!searchMode">There are no photo stored in <span x-text="currentPath + '/'"></span></div>
+                            <div class="sub header" x-show="searchMode">Try a different name, camera, ISO, f-number, month, year or orientation.</div>
                         </div>
                     </h4>
                 </div>

+ 224 - 0
src/web/Photo/photo.js

@@ -108,6 +108,19 @@ function photoListObject() {
         isLoadingMore: false, // guard: blocks new batch until DOM has updated
         sidebarOpen: !isMobile,  // start hidden on mobile, visible on desktop
 
+        // search state (tags input)
+        searchTags: [],         // committed filter chips: {label, value, type}
+        searchInput: '',        // text currently being typed in the box
+        searchMode: false,      // true while showing search results instead of a folder
+        suggestions: [],
+        showSuggestions: false,
+        suggestIndex: -1,       // keyboard-highlighted suggestion (-1 = none)
+        searchTotal: 0,
+        indexing: false,
+        indexStatusText: '',
+        _suggestTimer: null,
+        _searchTimer: null,
+
         // init
         init() {
             this.getFolderInfo();
@@ -117,6 +130,10 @@ function photoListObject() {
             this.restored = false;
             this.$nextTick(() => { this.setupInfiniteScroll(); });
 
+            // Kick off background (auto) indexing shortly after the first paint so
+            // it doesn't compete with the initial folder load.
+            setTimeout(() => { this.startAutoIndex(); }, 1200);
+
             const MOBILE_BP = 768;
             let _prevMobile = window.innerWidth <= MOBILE_BP;
             let _resizeTimer;
@@ -243,6 +260,213 @@ function photoListObject() {
                     this.loadMoreImages();
                 }
             });
+        },
+
+        // ── Search ────────────────────────────────────────────────────────────
+
+        suggestIcon(type) { return photoSuggestIcon(type); },
+
+        // The committed query is the chips; currentQuery also folds in the text
+        // still being typed so results update live.
+        committedQuery() { return this.searchTags.map(t => t.value).join(' '); },
+        currentQuery() {
+            let q = this.committedQuery();
+            const inp = this.searchInput.trim();
+            if (inp) q = (q ? q + ' ' : '') + inp;
+            return q;
+        },
+        searchActive() { return this.searchTags.length > 0 || this.searchInput.trim().length > 0; },
+
+        // Debounced autocomplete + live search on every keystroke.
+        onSearchInput() {
+            this.suggestIndex = -1;
+            const q = this.searchInput;
+            clearTimeout(this._suggestTimer);
+            this._suggestTimer = setTimeout(() => { this.fetchSuggestions(q); }, 150);
+            this.scheduleSearch();
+        },
+
+        fetchSuggestions(q) {
+            aoPhotoBackend("Photo/backend/searchSuggest.js", { q: q }).then(data => {
+                this.suggestions = (data && data.suggestions) ? data.suggestions : [];
+                this.showSuggestions = this.suggestions.length > 0;
+            }).catch(() => {
+                this.suggestions = [];
+                this.showSuggestions = false;
+            });
+        },
+
+        scheduleSearch() {
+            clearTimeout(this._searchTimer);
+            this._searchTimer = setTimeout(() => { this.runSearch(); }, 400);
+        },
+
+        // Add a chip from an explicit {label, value, type}.
+        addTag(tag) {
+            if (!tag || !tag.value) return;
+            // Ignore exact duplicates (same token already a chip).
+            if (this.searchTags.some(t => t.value === tag.value)) {
+                this.searchInput = '';
+                this.showSuggestions = false;
+                return;
+            }
+            this.searchTags.push({ label: tag.label, value: tag.value, type: tag.type || 'search' });
+            this.searchInput = '';
+            this.suggestions = [];
+            this.showSuggestions = false;
+            this.suggestIndex = -1;
+            this.runSearch();
+        },
+
+        // A picked autocomplete suggestion becomes a chip.
+        applySuggestion(s) { this.addTag({ label: s.label, value: s.value, type: s.type }); },
+
+        // Commit whatever text is in the box as a chip (Enter / Space).
+        commitInput() {
+            const text = this.searchInput.trim();
+            if (text.length === 0) return;
+            this.addTag(photoParseTagToken(text));
+        },
+
+        removeTag(i) {
+            this.searchTags.splice(i, 1);
+            this.runSearch();
+            this.$nextTick(() => { const el = document.getElementById('photo-search-input'); if (el) el.focus(); });
+        },
+
+        removeLastTag() {
+            if (this.searchTags.length > 0) {
+                this.searchTags.pop();
+                this.runSearch();
+            }
+        },
+
+        runSearch() {
+            // NOTE: this is also the live/debounced search fired while typing, so it
+            // must NOT close the autocomplete dropdown — only explicit actions
+            // (pick/commit/Escape/click-outside/clear) hide it. Closing it here made
+            // the dropdown flash and vanish ~400ms after each keystroke.
+            clearTimeout(this._searchTimer);
+            const q = this.currentQuery();
+            if (q.length === 0) {
+                // Nothing to search — fall back to normal folder browsing.
+                if (this.searchMode) { this.searchMode = false; this.searchTotal = 0; this.getFolderInfo(); }
+                return;
+            }
+            this.searchMode = true;
+            aoPhotoBackend("Photo/backend/searchPhotos.js", {
+                q: q,
+                sort: 'taken_desc',
+                limit: 1000
+            }).then(data => {
+                const results = (data && data.results) ? data.results : [];
+                this.searchTotal = (data && typeof data.total === 'number') ? data.total : results.length;
+                // Reuse the existing grid: it only needs {filepath, filesize}.
+                this.allImages = results.map(r => ({ filepath: r.filepath, filesize: r.filesize }));
+                this.images = this.allImages.slice(0, PAGE_SIZE);
+                this.hasMoreImages = this.allImages.length > PAGE_SIZE;
+                this.isLoadingMore = false;
+                this.folders = [];
+                if (this.allImages.length == 0) { $("#noimg").show(); } else { $("#noimg").hide(); }
+                this.$nextTick(() => { updateImageSizes(); });
+            }).catch(() => { /* leave current view untouched on error */ });
+        },
+
+        clearSearch() {
+            this.searchTags = [];
+            this.searchInput = '';
+            this.suggestions = [];
+            this.showSuggestions = false;
+            this.suggestIndex = -1;
+            this.searchTotal = 0;
+            this.searchMode = false;
+            clearTimeout(this._searchTimer);
+            this.getFolderInfo();   // restore normal folder browsing
+        },
+
+        onSearchKeydown(e) {
+            if (e.key === 'Enter') {
+                e.preventDefault();
+                if (this.showSuggestions && this.suggestIndex >= 0 && this.suggestions[this.suggestIndex]) {
+                    this.applySuggestion(this.suggestions[this.suggestIndex]);
+                } else if (this.searchInput.trim().length > 0) {
+                    this.commitInput();
+                } else {
+                    this.runSearch();
+                }
+            } else if (e.key === ' ') {
+                // Space turns the current token into a chip (unless mid-quote/"key:").
+                if (photoInputCommittable(this.searchInput)) {
+                    e.preventDefault();
+                    this.commitInput();
+                }
+            } else if (e.key === 'Backspace') {
+                if (this.searchInput.length === 0 && this.searchTags.length > 0) {
+                    e.preventDefault();
+                    this.removeLastTag();
+                }
+            } else if (e.key === 'ArrowDown') {
+                if (this.showSuggestions) {
+                    e.preventDefault();
+                    this.suggestIndex = Math.min(this.suggestIndex + 1, this.suggestions.length - 1);
+                }
+            } else if (e.key === 'ArrowUp') {
+                if (this.showSuggestions) {
+                    e.preventDefault();
+                    this.suggestIndex = Math.max(this.suggestIndex - 1, -1);
+                }
+            } else if (e.key === 'Escape') {
+                if (this.showSuggestions) { this.showSuggestions = false; }
+                else if (this.searchActive()) { this.clearSearch(); }
+            }
+        },
+
+        // ── Auto indexing ─────────────────────────────────────────────────────
+        // Loops indexPhotos.js in the background until the whole library is
+        // indexed. Incremental, so steady-state runs finish in a single pass.
+        startAutoIndex() {
+            if (this.indexing) return;
+            this.indexing = true;
+            const step = () => {
+                aoPhotoBackend("Photo/backend/indexPhotos.js", { mode: 'incremental' }).then(data => {
+                    if (data && data.error) { this.indexing = false; this.indexStatusText = ''; return; }
+                    const total = (data && data.total) ? data.total : 0;
+                    if (data && data.hasMore) {
+                        this.indexStatusText = 'Indexing… ' + total + ' photos';
+                        setTimeout(step, 50);
+                    } else {
+                        this.indexing = false;
+                        this.indexStatusText = total ? (total + ' photos indexed') : '';
+                        setTimeout(() => { if (!this.indexing) this.indexStatusText = ''; }, 4000);
+                    }
+                }).catch(() => { this.indexing = false; this.indexStatusText = ''; });
+            };
+            step();
+        },
+
+        // Force a full re-index (used by the "Rebuild" action). The first request
+        // wipes the index ("full"); subsequent rounds loop incrementally so the
+        // batch loop advances to completion.
+        rebuildIndex() {
+            if (this.indexing) return;
+            this.indexing = true;
+            this.indexStatusText = 'Rebuilding index…';
+            const step = (mode) => {
+                aoPhotoBackend("Photo/backend/indexPhotos.js", { mode: mode }).then(data => {
+                    if (data && data.error) { this.indexing = false; this.indexStatusText = ''; return; }
+                    const total = (data && data.total) ? data.total : 0;
+                    if (data && data.hasMore) {
+                        this.indexStatusText = 'Rebuilding… ' + total + ' photos';
+                        setTimeout(() => step('incremental'), 50);
+                    } else {
+                        this.indexing = false;
+                        this.indexStatusText = total ? (total + ' photos indexed') : '';
+                        if (this.searchMode) this.runSearch();
+                        setTimeout(() => { if (!this.indexing) this.indexStatusText = ''; }, 4000);
+                    }
+                }).catch(() => { this.indexing = false; this.indexStatusText = ''; });
+            };
+            step('full');
         }
     }
 }

+ 141 - 0
src/web/Photo/search.js

@@ -0,0 +1,141 @@
+/*
+    search.js
+
+    Front-end helpers for the Photo search feature. The reactive state and the
+    Alpine methods live in photoListObject() (photo.js); this file only holds the
+    pure helpers they delegate to, so the search logic stays out of the big
+    component object.
+
+    Backend endpoints used (all under system/ajgi/interface):
+      Photo/backend/searchPhotos.js   - run a search
+      Photo/backend/searchSuggest.js  - autocomplete suggestions
+      Photo/backend/indexPhotos.js    - incremental (auto) indexing
+      Photo/backend/indexStatus.js    - index statistics
+*/
+
+// POST a JSON payload to a Photo AGI backend script and resolve with parsed JSON.
+function aoPhotoBackend(script, payload) {
+    return fetch(ao_root + "system/ajgi/interface?script=" + script, {
+        method: 'POST',
+        cache: 'no-cache',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(payload || {})
+    }).then(function (resp) {
+        return resp.json();
+    });
+}
+
+// Map a suggestion type to a Semantic-UI icon class.
+function photoSuggestIcon(type) {
+    switch (type) {
+        case 'camera':
+            return 'camera';
+        case 'lens':
+            return 'expand';
+        case 'date':
+            return 'calendar alternate outline';
+        case 'type':
+            return 'file image outline';
+        case 'file':
+            return 'image outline';
+        case 'filter':
+            return 'filter';
+        default:
+            return 'search';
+    }
+}
+
+// Title-case a single word.
+function photoCap(s) {
+    s = '' + s;
+    return s.charAt(0).toUpperCase() + s.slice(1);
+}
+
+var PHOTO_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
+    'July', 'August', 'September', 'October', 'November', 'December'];
+var PHOTO_MONTHS_ABBR = ['jan', 'feb', 'mar', 'apr', 'may', 'jun',
+    'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
+
+function photoMonthNameToNum(s) {
+    s = ('' + s).trim().toLowerCase();
+    var i = PHOTO_MONTHS.findIndex(function (m) { return m.toLowerCase() === s; });
+    if (i >= 0) return i + 1;
+    i = PHOTO_MONTHS_ABBR.indexOf(s);
+    if (i >= 0) return i + 1;
+    var n = parseInt(s);
+    if (!isNaN(n) && n >= 1 && n <= 12) return n;
+    return null;
+}
+
+// Turn a raw query token the user typed into a display tag {label, value, type}.
+// `value` is always a token that the backend query parser understands; `label`
+// is the friendly text shown on the chip; `type` drives the chip icon/colour.
+function photoParseTagToken(token) {
+    token = ('' + token).trim();
+    var lower = token.toLowerCase();
+    var colon = token.indexOf(':');
+    var key = colon > 0 ? lower.substring(0, colon) : '';
+    var val = colon > 0 ? token.substring(colon + 1) : '';
+    function unq(s) { return ('' + s).replace(/^"(.*)"$/, '$1'); }
+
+    var fm = lower.match(/^f\/?(\d+(?:\.\d+)?)$/);
+    if (colon < 0 && fm) return { label: 'f/' + fm[1], value: 'f/' + fm[1], type: 'filter' };
+    var fmm = lower.match(/^(\d+(?:\.\d+)?)mm$/);
+    if (colon < 0 && fmm) return { label: fmm[1] + 'mm', value: fmm[1] + 'mm', type: 'filter' };
+    if (colon < 0 && lower.charAt(0) === '.') return { label: lower, value: lower, type: 'type' };
+    if (colon < 0 && (lower === 'landscape' || lower === 'portrait' || lower === 'square'))
+        return { label: photoCap(lower), value: lower, type: 'filter' };
+    if (colon < 0 && lower === 'raw') return { label: 'RAW', value: 'raw', type: 'type' };
+    var mn = (colon < 0) ? photoMonthNameToNum(lower) : null;
+    if (colon < 0 && mn && /^[a-z]+$/.test(lower)) return { label: PHOTO_MONTHS[mn - 1], value: 'month:' + mn, type: 'date' };
+    if (colon < 0 && /^\d{4}$/.test(lower)) return { label: lower, value: lower, type: 'date' };
+
+    if (colon > 0) {
+        switch (key) {
+            case 'iso': return { label: 'ISO ' + val, value: 'iso:' + val, type: 'filter' };
+            case 'f': case 'aperture': case 'fnumber': return { label: 'f/' + val.replace(/^\//, ''), value: token, type: 'filter' };
+            case 'focal': case 'fl': return { label: val.replace(/mm$/i, '') + 'mm', value: token, type: 'filter' };
+            case 'mp': case 'megapixels': return { label: val + ' MP', value: token, type: 'filter' };
+            case 'width': case 'w': return { label: 'Width ' + val, value: token, type: 'filter' };
+            case 'height': case 'h': return { label: 'Height ' + val, value: token, type: 'filter' };
+            case 'model': case 'camera': return { label: unq(val), value: token, type: 'camera' };
+            case 'make': case 'brand': return { label: unq(val), value: token, type: 'camera' };
+            case 'lens': return { label: unq(val), value: token, type: 'lens' };
+            case 'ext': case 'type': return { label: '.' + unq(val).replace(/^\./, ''), value: token, type: 'type' };
+            case 'name': case 'filename': return { label: unq(val), value: token, type: 'file' };
+            case 'orientation': return { label: photoCap(unq(val)), value: token, type: 'filter' };
+            case 'month':
+                var ms = ('' + val).split(',').map(function (x) { var n = photoMonthNameToNum(x); return n ? PHOTO_MONTHS[n - 1] : x; });
+                return { label: ms.join(', '), value: token, type: 'date' };
+            case 'taken': case 'date': case 'year': return { label: unq(val), value: token, type: 'date' };
+            case 'modified': case 'mtime': return { label: 'Modified ' + unq(val), value: token, type: 'date' };
+            case 'before': return { label: 'Before ' + unq(val), value: token, type: 'date' };
+            case 'after': return { label: 'After ' + unq(val), value: token, type: 'date' };
+            default: return { label: token, value: token, type: 'search' };
+        }
+    }
+    return { label: token, value: token, type: 'search' };
+}
+
+// Whether the current input token can be committed as a chip on space:
+// non-empty, not waiting for a value ("iso:"), and not inside an open quote.
+function photoInputCommittable(s) {
+    s = ('' + s).trim();
+    if (s.length === 0) return false;
+    if (s.charAt(s.length - 1) === ':') return false;
+    if (((s.match(/"/g) || []).length) % 2 !== 0) return false;
+    return true;
+}
+
+// Simple debounce used for the autocomplete fetch.
+function photoDebounce(fn, ms) {
+    var timer = null;
+    return function () {
+        var ctx = this;
+        var args = arguments;
+        clearTimeout(timer);
+        timer = setTimeout(function () {
+            fn.apply(ctx, args);
+        }, ms);
+    };
+}