imagedb.js 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111
  1. /*
  2. imagedb.js
  3. Photo index database helper library for the ArozOS Photo module.
  4. This is a *shared* library (loaded via includes("imagedb.js")). It provides
  5. everything the search / indexing backend scripts need:
  6. - The per-user SQLite photo-index schema + open/migrate helper
  7. - Photo metadata extraction (file name, resolution, dates, EXIF shooting
  8. parameters) via the AGI imagelib / filelib libraries
  9. - Incremental upsert / lookup helpers used by indexPhotos.js
  10. - The iOS-style free-text query parser + parameterised SQL builder shared
  11. by searchPhotos.js and searchSuggest.js
  12. - Exclude-folder configuration (also consumed by exclude.js)
  13. The index is stored per-user at INDEX_DB_PATH, so a user only ever sees their
  14. own photos. The index is a derived cache of the file system: it can always be
  15. rebuilt from scratch, which is why a schema-version bump simply drops & rebuilds.
  16. NOTE: AGI scripts run on the Otto VM (ECMAScript 5.1). Keep this file ES5 —
  17. no let/const, arrow functions, or template literals.
  18. Requires (provided by this file): sqlite, filelib, imagelib
  19. */
  20. requirelib("sqlite");
  21. requirelib("filelib");
  22. requirelib("imagelib");
  23. // Per-user SQLite index location. The sqlite lib creates parent dirs on open.
  24. var INDEX_DB_PATH = "user:/.appdata/photo/photoindex.db";
  25. // Bump when the schema below changes; openIndexDB() will rebuild the cache.
  26. var SCHEMA_VERSION = 1;
  27. // Image / RAW extension sets (kept in sync with constants.js + listFolder.js).
  28. var IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "webp", "gif", "arw", "cr2", "dng", "nef", "raf", "orf"];
  29. var RAW_EXTENSIONS = ["arw", "cr2", "dng", "nef", "raf", "orf"];
  30. /* ------------------------------------------------------------------ *
  31. * Small path helpers
  32. * ------------------------------------------------------------------ */
  33. function db_getExt(filename) {
  34. var parts = ("" + filename).split(".");
  35. if (parts.length < 2) {
  36. return "";
  37. }
  38. return parts.pop().toLowerCase();
  39. }
  40. function db_isImageFile(filename) {
  41. return IMAGE_EXTENSIONS.indexOf(db_getExt(filename)) >= 0;
  42. }
  43. function db_isRawImage(filename) {
  44. return RAW_EXTENSIONS.indexOf(db_getExt(filename)) >= 0;
  45. }
  46. function db_basename(filepath) {
  47. return ("" + filepath).split("/").pop();
  48. }
  49. function db_dirname(filepath) {
  50. var t = ("" + filepath).split("/");
  51. t.pop();
  52. return t.join("/");
  53. }
  54. // Hidden path: any dot-prefixed segment, e.g. the ArozOS ".metadata/.cache"
  55. // thumbnail folders that the file manager generates inside every browsed
  56. // directory, or AppleDouble "._*" files. These are caches — never user
  57. // photos — and the Photo UI hides dot-folders from browsing, so the indexer
  58. // must skip them too or cached thumbnails pollute search and date grouping.
  59. function db_isHiddenPath(filepath) {
  60. var parts = ("" + filepath).split("/");
  61. // parts[0] is the vroot ("user:"), which is never dot-prefixed.
  62. for (var i = 0; i < parts.length; i++) {
  63. if (parts[i].charAt(0) === ".") {
  64. return true;
  65. }
  66. }
  67. return false;
  68. }
  69. /* ------------------------------------------------------------------ *
  70. * Schema + open/migrate
  71. * ------------------------------------------------------------------ */
  72. function ensureSchema(db) {
  73. db.exec(
  74. "CREATE TABLE IF NOT EXISTS photos (" +
  75. "id INTEGER PRIMARY KEY AUTOINCREMENT," +
  76. "filepath TEXT UNIQUE NOT NULL," + // virtual path, e.g. user:/Photo/a.jpg
  77. "filename TEXT NOT NULL," +
  78. "filename_lc TEXT NOT NULL," + // lowercased name for case-insensitive search
  79. "ext TEXT," + // lowercase extension without dot
  80. "folder TEXT," + // parent folder virtual path
  81. "filesize INTEGER," + // bytes
  82. "width INTEGER," + // pixels
  83. "height INTEGER," + // pixels
  84. "megapixels REAL," + // width*height / 1e6
  85. "orientation TEXT," + // landscape | portrait | square
  86. "taken_date INTEGER," + // unix sec, EXIF DateTimeOriginal (fallback mtime)
  87. "modified_date INTEGER," + // unix sec, file modification time
  88. "camera_make TEXT," +
  89. "camera_model TEXT," +
  90. "lens_model TEXT," +
  91. "focal_length REAL," + // mm
  92. "aperture REAL," + // f-number
  93. "shutter REAL," + // exposure time in seconds
  94. "shutter_label TEXT," + // human readable, e.g. 1/250
  95. "iso INTEGER," +
  96. "has_exif INTEGER DEFAULT 0," +
  97. "indexed_at INTEGER" + // unix sec this row was (re)indexed
  98. ")"
  99. );
  100. db.exec("CREATE INDEX IF NOT EXISTS idx_photos_filename_lc ON photos(filename_lc)");
  101. db.exec("CREATE INDEX IF NOT EXISTS idx_photos_taken ON photos(taken_date)");
  102. db.exec("CREATE INDEX IF NOT EXISTS idx_photos_modified ON photos(modified_date)");
  103. db.exec("CREATE INDEX IF NOT EXISTS idx_photos_model ON photos(camera_model)");
  104. db.exec("CREATE INDEX IF NOT EXISTS idx_photos_iso ON photos(iso)");
  105. db.exec("CREATE INDEX IF NOT EXISTS idx_photos_ext ON photos(ext)");
  106. db.exec("CREATE INDEX IF NOT EXISTS idx_photos_folder ON photos(folder)");
  107. db.exec("CREATE TABLE IF NOT EXISTS index_meta (key TEXT PRIMARY KEY, value TEXT)");
  108. // User-assigned star ratings (0-5). Kept in a *separate* table keyed by the
  109. // virtual path so it survives a photos-table rebuild / schema bump / full
  110. // re-index — those only ever drop & repopulate the derived `photos` cache,
  111. // never the user's own ratings.
  112. db.exec(
  113. "CREATE TABLE IF NOT EXISTS photo_ratings (" +
  114. "filepath TEXT PRIMARY KEY NOT NULL," +
  115. "rating INTEGER NOT NULL," + // 1..5 (a 0 rating is stored as "no row")
  116. "updated_at INTEGER" + // unix sec the rating was last set
  117. ")"
  118. );
  119. }
  120. /* ------------------------------------------------------------------ *
  121. * User-assigned star ratings
  122. * ------------------------------------------------------------------ */
  123. // Clamp an arbitrary input to an integer in the 0..5 star range.
  124. function db_clampRating(v) {
  125. var n = parseInt(v, 10);
  126. if (isNaN(n)) {
  127. return 0;
  128. }
  129. if (n < 0) {
  130. return 0;
  131. }
  132. if (n > 5) {
  133. return 5;
  134. }
  135. return n;
  136. }
  137. // Return the star rating (0 = unrated) for a single photo.
  138. function db_getRating(db, filepath) {
  139. if (db == null || !filepath) {
  140. return 0;
  141. }
  142. var row = db.queryRow("SELECT rating FROM photo_ratings WHERE filepath = ?", [filepath]);
  143. if (row && row.rating) {
  144. return db_clampRating(row.rating);
  145. }
  146. return 0;
  147. }
  148. // Set (or, when rating <= 0, clear) the star rating for a single photo.
  149. // Returns the stored rating (0 when cleared).
  150. function db_setRating(db, filepath, rating) {
  151. if (db == null || !filepath) {
  152. return 0;
  153. }
  154. var r = db_clampRating(rating);
  155. if (r <= 0) {
  156. db.exec("DELETE FROM photo_ratings WHERE filepath = ?", [filepath]);
  157. return 0;
  158. }
  159. db.exec(
  160. "INSERT INTO photo_ratings (filepath, rating, updated_at) VALUES (?,?,?) " +
  161. "ON CONFLICT(filepath) DO UPDATE SET rating = excluded.rating, updated_at = excluded.updated_at",
  162. [filepath, r, Math.floor(Date.now() / 1000)]
  163. );
  164. return r;
  165. }
  166. function metaGet(db, key, fallback) {
  167. var row = db.queryRow("SELECT value FROM index_meta WHERE key = ?", [key]);
  168. if (row && row.value !== undefined && row.value !== null) {
  169. return row.value;
  170. }
  171. return fallback;
  172. }
  173. function metaSet(db, key, value) {
  174. db.exec(
  175. "INSERT INTO index_meta (key, value) VALUES (?, ?) " +
  176. "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
  177. [key, "" + value]
  178. );
  179. }
  180. // Open (creating if needed), run migrations and return the connection (or null).
  181. // Returns null when the SQLite library is unavailable (e.g. the few build
  182. // targets without a modernc C-runtime port), so callers degrade gracefully
  183. // instead of throwing.
  184. function openIndexDB() {
  185. if (typeof sqlite === "undefined" || !sqlite || typeof sqlite.open !== "function") {
  186. return null;
  187. }
  188. var db = sqlite.open(INDEX_DB_PATH);
  189. if (db == null) {
  190. return null;
  191. }
  192. ensureSchema(db);
  193. var current = parseInt(metaGet(db, "schema_version", "0")) || 0;
  194. if (current !== SCHEMA_VERSION) {
  195. // The index is a derived cache, so a forward bump simply rebuilds it.
  196. if (current !== 0 && current < SCHEMA_VERSION) {
  197. db.exec("DROP TABLE IF EXISTS photos");
  198. ensureSchema(db);
  199. }
  200. metaSet(db, "schema_version", SCHEMA_VERSION);
  201. }
  202. return db;
  203. }
  204. /* ------------------------------------------------------------------ *
  205. * EXIF parsing helpers
  206. *
  207. * imagelib.getExif() returns a map whose values are mostly JSON-encoded
  208. * strings, e.g. Make => "\"Canon\"" and FNumber => "\"28/10\"". We normalise
  209. * each value by attempting a JSON.parse, then interpret it as string/number.
  210. * This mirrors the proven parsing already used in photo.js.
  211. * ------------------------------------------------------------------ */
  212. function exifRaw(exif, key) {
  213. if (!exif || exif[key] === undefined || exif[key] === null) {
  214. return undefined;
  215. }
  216. var v = exif[key];
  217. if (typeof v === "string") {
  218. try {
  219. v = JSON.parse(v);
  220. } catch (e) {
  221. /* keep the raw string */
  222. }
  223. }
  224. return v;
  225. }
  226. function exifFirst(v) {
  227. if (Array.isArray(v)) {
  228. return v.length ? v[0] : undefined;
  229. }
  230. return v;
  231. }
  232. // Parse an EXIF rational/number: "28/10" => 2.8, "100" => 100
  233. function exifToNumber(v) {
  234. if (v === undefined || v === null) {
  235. return null;
  236. }
  237. if (typeof v === "number") {
  238. return v;
  239. }
  240. var s = ("" + v).trim();
  241. if (s.indexOf("/") >= 0) {
  242. var p = s.split("/");
  243. if (p.length === 2) {
  244. var num = parseFloat(p[0]);
  245. var den = parseFloat(p[1]);
  246. if (!isNaN(num) && !isNaN(den) && den !== 0) {
  247. return num / den;
  248. }
  249. }
  250. }
  251. var n = parseFloat(s);
  252. return isNaN(n) ? null : n;
  253. }
  254. function exifNumber(exif, key) {
  255. return exifToNumber(exifFirst(exifRaw(exif, key)));
  256. }
  257. function exifString(exif, key) {
  258. var v = exifFirst(exifRaw(exif, key));
  259. if (v === undefined || v === null) {
  260. return null;
  261. }
  262. var s = ("" + v).trim();
  263. return s.length ? s : null;
  264. }
  265. function exifInt(exif, key) {
  266. var n = exifNumber(exif, key);
  267. return n === null ? null : Math.round(n);
  268. }
  269. // "2023:11:05 14:30:00" => unix seconds (interpreted as UTC for stable ranges).
  270. function exifDateToUnix(s) {
  271. if (!s) {
  272. return null;
  273. }
  274. var m = ("" + s).match(/^(\d{4})[:\-](\d{2})[:\-](\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
  275. if (!m) {
  276. return null;
  277. }
  278. var t = Date.UTC(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3]),
  279. parseInt(m[4]), parseInt(m[5]), parseInt(m[6]));
  280. if (isNaN(t)) {
  281. return null;
  282. }
  283. return Math.floor(t / 1000);
  284. }
  285. function shutterLabel(seconds) {
  286. if (seconds === null || seconds === undefined || seconds <= 0) {
  287. return null;
  288. }
  289. if (seconds < 1) {
  290. return "1/" + Math.round(1 / seconds);
  291. }
  292. return (Math.round(seconds * 10) / 10) + "s";
  293. }
  294. /* ------------------------------------------------------------------ *
  295. * Metadata extraction
  296. * ------------------------------------------------------------------ */
  297. // Build the full metadata row for a single image file. modifiedUnix / filesize
  298. // can be supplied to avoid duplicate stat calls during a walk.
  299. function extractPhotoMeta(filepath, modifiedUnix, filesize) {
  300. var filename = db_basename(filepath);
  301. var ext = db_getExt(filename);
  302. if (modifiedUnix === undefined || modifiedUnix === null) {
  303. modifiedUnix = filelib.mtime(filepath, true);
  304. if (modifiedUnix === false) {
  305. modifiedUnix = null;
  306. }
  307. }
  308. if (filesize === undefined || filesize === null) {
  309. filesize = filelib.filesize(filepath);
  310. }
  311. var meta = {
  312. filepath: filepath,
  313. filename: filename,
  314. filename_lc: filename.toLowerCase(),
  315. ext: ext,
  316. folder: db_dirname(filepath),
  317. filesize: filesize || 0,
  318. width: null,
  319. height: null,
  320. megapixels: null,
  321. orientation: null,
  322. taken_date: modifiedUnix || null,
  323. modified_date: modifiedUnix || null,
  324. camera_make: null,
  325. camera_model: null,
  326. lens_model: null,
  327. focal_length: null,
  328. aperture: null,
  329. shutter: null,
  330. shutter_label: null,
  331. iso: null,
  332. has_exif: 0,
  333. indexed_at: Math.floor(Date.now() / 1000)
  334. };
  335. // Resolution (best effort; RAW may fail here and fall back to EXIF below).
  336. try {
  337. var dim = imagelib.getImageDimension(filepath);
  338. if (dim && dim[0] && dim[1]) {
  339. meta.width = dim[0];
  340. meta.height = dim[1];
  341. }
  342. } catch (e) {
  343. /* ignore — fall back to EXIF dimensions */
  344. }
  345. // EXIF: shooting parameters, taken date and possibly resolution.
  346. var exif = null;
  347. try {
  348. if (imagelib.hasExif(filepath)) {
  349. exif = JSON.parse(imagelib.getExif(filepath));
  350. }
  351. } catch (e) {
  352. exif = null;
  353. }
  354. if (exif && typeof exif === "object") {
  355. meta.has_exif = 1;
  356. if (!meta.width || !meta.height) {
  357. var w = exifInt(exif, "PixelXDimension");
  358. var h = exifInt(exif, "PixelYDimension");
  359. if (w && h) {
  360. meta.width = w;
  361. meta.height = h;
  362. }
  363. }
  364. var taken = exifDateToUnix(exifString(exif, "DateTimeOriginal")) ||
  365. exifDateToUnix(exifString(exif, "DateTimeDigitized")) ||
  366. exifDateToUnix(exifString(exif, "DateTime"));
  367. if (taken) {
  368. meta.taken_date = taken;
  369. }
  370. meta.camera_make = exifString(exif, "Make");
  371. meta.camera_model = exifString(exif, "Model");
  372. meta.lens_model = exifString(exif, "LensModel");
  373. meta.focal_length = exifNumber(exif, "FocalLength");
  374. meta.aperture = exifNumber(exif, "FNumber");
  375. var expTime = exifNumber(exif, "ExposureTime");
  376. if (expTime !== null) {
  377. meta.shutter = expTime;
  378. meta.shutter_label = shutterLabel(expTime);
  379. }
  380. meta.iso = exifInt(exif, "ISOSpeedRatings");
  381. }
  382. // Derived geometry fields.
  383. if (meta.width && meta.height) {
  384. meta.megapixels = Math.round((meta.width * meta.height) / 1000000 * 10) / 10;
  385. if (meta.width > meta.height) {
  386. meta.orientation = "landscape";
  387. } else if (meta.width < meta.height) {
  388. meta.orientation = "portrait";
  389. } else {
  390. meta.orientation = "square";
  391. }
  392. }
  393. return meta;
  394. }
  395. // Insert or update one photo row keyed by its (unique) virtual path.
  396. function upsertPhoto(db, m) {
  397. db.exec(
  398. "INSERT INTO photos (filepath, filename, filename_lc, ext, folder, filesize," +
  399. " width, height, megapixels, orientation, taken_date, modified_date," +
  400. " camera_make, camera_model, lens_model, focal_length, aperture, shutter," +
  401. " shutter_label, iso, has_exif, indexed_at)" +
  402. " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" +
  403. " ON CONFLICT(filepath) DO UPDATE SET" +
  404. " filename=excluded.filename, filename_lc=excluded.filename_lc, ext=excluded.ext," +
  405. " folder=excluded.folder, filesize=excluded.filesize, width=excluded.width," +
  406. " height=excluded.height, megapixels=excluded.megapixels, orientation=excluded.orientation," +
  407. " taken_date=excluded.taken_date, modified_date=excluded.modified_date," +
  408. " camera_make=excluded.camera_make, camera_model=excluded.camera_model," +
  409. " lens_model=excluded.lens_model, focal_length=excluded.focal_length," +
  410. " aperture=excluded.aperture, shutter=excluded.shutter, shutter_label=excluded.shutter_label," +
  411. " iso=excluded.iso, has_exif=excluded.has_exif, indexed_at=excluded.indexed_at",
  412. [m.filepath, m.filename, m.filename_lc, m.ext, m.folder, m.filesize,
  413. m.width, m.height, m.megapixels, m.orientation, m.taken_date, m.modified_date,
  414. m.camera_make, m.camera_model, m.lens_model, m.focal_length, m.aperture, m.shutter,
  415. m.shutter_label, m.iso, m.has_exif, m.indexed_at]
  416. );
  417. }
  418. /* ------------------------------------------------------------------ *
  419. * Photo roots + exclude folders
  420. * ------------------------------------------------------------------ */
  421. // Photo library roots (mirrors backend/listRoots.js): every real (non-virtual)
  422. // storage that has a /Photo folder, plus the user's home Photo folder.
  423. function getPhotoRoots() {
  424. var roots = [];
  425. var seen = {};
  426. for (var i = 0; i < USER_VROOTS.length; i++) {
  427. var r = USER_VROOTS[i];
  428. if (r.Filesystem === "virtual") {
  429. continue;
  430. }
  431. var p = r.UUID + ":/Photo";
  432. if (!seen[p] && filelib.fileExists(p)) {
  433. roots.push(p);
  434. seen[p] = true;
  435. }
  436. }
  437. if (!seen["user:/Photo"] && filelib.fileExists("user:/Photo")) {
  438. roots.push("user:/Photo");
  439. }
  440. return roots;
  441. }
  442. // Exclude list is stored as a JSON array string in index_meta. Each entry is a
  443. // path fragment; any file whose path contains "/<fragment>/" is skipped.
  444. function getExcludeFolders() {
  445. var db = openIndexDB();
  446. if (db == null) {
  447. return "[]";
  448. }
  449. var raw = metaGet(db, "exclude_folders", "[]");
  450. db.close();
  451. return raw;
  452. }
  453. function setExcludeFolders(folders) {
  454. var db = openIndexDB();
  455. if (db == null) {
  456. return;
  457. }
  458. var arr = folders;
  459. if (typeof folders === "string") {
  460. try {
  461. arr = JSON.parse(folders);
  462. } catch (e) {
  463. arr = [];
  464. }
  465. }
  466. if (!Array.isArray(arr)) {
  467. arr = [];
  468. }
  469. metaSet(db, "exclude_folders", JSON.stringify(arr));
  470. db.close();
  471. }
  472. function parseExcludeList(raw) {
  473. try {
  474. var arr = JSON.parse(raw);
  475. if (Array.isArray(arr)) {
  476. return arr;
  477. }
  478. } catch (e) {
  479. /* ignore */
  480. }
  481. return [];
  482. }
  483. function isExcluded(filepath, excludeList) {
  484. if (!excludeList || !excludeList.length) {
  485. return false;
  486. }
  487. var p = "/" + filepath + "/";
  488. for (var i = 0; i < excludeList.length; i++) {
  489. var frag = ("" + excludeList[i]).replace(/^\/+|\/+$/g, "");
  490. if (frag.length === 0) {
  491. continue;
  492. }
  493. if (p.indexOf("/" + frag + "/") >= 0) {
  494. return true;
  495. }
  496. }
  497. return false;
  498. }
  499. /* ------------------------------------------------------------------ *
  500. * Query parsing (iOS-style free text) + SQL builder
  501. * ------------------------------------------------------------------ */
  502. // Numeric range token: ">800", "<1600", "800-3200", "800..3200", "100".
  503. function parseRange(s) {
  504. s = ("" + s).trim();
  505. var m;
  506. if ((m = s.match(/^>=?\s*(.+)$/))) {
  507. return { min: parseFloat(m[1]), max: null };
  508. }
  509. if ((m = s.match(/^<=?\s*(.+)$/))) {
  510. return { min: null, max: parseFloat(m[1]) };
  511. }
  512. if ((m = s.match(/^(.+?)\.\.(.+)$/))) {
  513. return { min: parseFloat(m[1]), max: parseFloat(m[2]) };
  514. }
  515. if ((m = s.match(/^([0-9.]+)-([0-9.]+)$/))) {
  516. return { min: parseFloat(m[1]), max: parseFloat(m[2]) };
  517. }
  518. var v = parseFloat(s);
  519. if (isNaN(v)) {
  520. return null;
  521. }
  522. return { min: v, max: v };
  523. }
  524. // Date token => unix seconds. endOfDay pads to the *end* of the given period
  525. // (end of year / month / day) so "2023", "2023-06" and "2023-06-15" all bound
  526. // their period correctly regardless of how many days the month has.
  527. function parseDateToUnix(s, endOfDay) {
  528. s = ("" + s).trim();
  529. var m = s.match(/^(\d{4})(?:[\-\/](\d{1,2}))?(?:[\-\/](\d{1,2}))?$/);
  530. if (!m) {
  531. return null;
  532. }
  533. var y = parseInt(m[1]);
  534. var hasMonth = m[2] !== undefined;
  535. var hasDay = m[3] !== undefined;
  536. var mo = hasMonth ? parseInt(m[2]) - 1 : 0;
  537. var d = hasDay ? parseInt(m[3]) : 1;
  538. if (!endOfDay) {
  539. var t0 = Date.UTC(y, mo, d, 0, 0, 0);
  540. return isNaN(t0) ? null : Math.floor(t0 / 1000);
  541. }
  542. // End of the specified period: start of the next period minus one second.
  543. var t;
  544. if (!hasMonth) {
  545. t = Date.UTC(y + 1, 0, 1, 0, 0, 0) - 1000; // end of year
  546. } else if (!hasDay) {
  547. t = Date.UTC(y, mo + 1, 1, 0, 0, 0) - 1000; // end of month
  548. } else {
  549. t = Date.UTC(y, mo, d, 23, 59, 59); // end of day
  550. }
  551. return isNaN(t) ? null : Math.floor(t / 1000);
  552. }
  553. function parseDateRange(s) {
  554. s = ("" + s).trim();
  555. var m;
  556. if ((m = s.match(/^>=?\s*(.+)$/))) {
  557. return { min: parseDateToUnix(m[1], false), max: null };
  558. }
  559. if ((m = s.match(/^<=?\s*(.+)$/))) {
  560. return { min: null, max: parseDateToUnix(m[1], true) };
  561. }
  562. if ((m = s.match(/^(.+?)\.\.(.+)$/))) {
  563. return { min: parseDateToUnix(m[1], false), max: parseDateToUnix(m[2], true) };
  564. }
  565. var single = parseDateToUnix(s, false);
  566. if (single === null) {
  567. return null;
  568. }
  569. return { min: single, max: parseDateToUnix(s, true) };
  570. }
  571. // Append one numeric range condition to a category list. Each token contributes
  572. // its own {min,max}; ranges within a category are OR-ed together at build time.
  573. function pushRange(filter, field, r) {
  574. if (!r) {
  575. return;
  576. }
  577. var hasMin = r.min !== null && r.min !== undefined && !isNaN(r.min);
  578. var hasMax = r.max !== null && r.max !== undefined && !isNaN(r.max);
  579. if (!hasMin && !hasMax) {
  580. return;
  581. }
  582. if (!filter[field]) {
  583. filter[field] = [];
  584. }
  585. filter[field].push({ min: hasMin ? r.min : null, max: hasMax ? r.max : null });
  586. }
  587. // Append one date range condition to a category list (OR-ed at build time).
  588. function pushDate(filter, field, r) {
  589. if (!r) {
  590. return;
  591. }
  592. var hasMin = r.min !== null && r.min !== undefined;
  593. var hasMax = r.max !== null && r.max !== undefined;
  594. if (!hasMin && !hasMax) {
  595. return;
  596. }
  597. if (!filter[field]) {
  598. filter[field] = [];
  599. }
  600. filter[field].push({ min: hasMin ? r.min : null, max: hasMax ? r.max : null });
  601. }
  602. var MONTH_NAMES = ["january", "february", "march", "april", "may", "june",
  603. "july", "august", "september", "october", "november", "december"];
  604. var MONTH_ABBR = ["jan", "feb", "mar", "apr", "may", "jun",
  605. "jul", "aug", "sep", "oct", "nov", "dec"];
  606. // Whether a bare word is a month name / abbreviation (not a number).
  607. function isMonthName(s) {
  608. s = ("" + s).trim().toLowerCase();
  609. return MONTH_NAMES.indexOf(s) >= 0 || MONTH_ABBR.indexOf(s) >= 0;
  610. }
  611. // Month name / abbreviation / number => 1..12, or null.
  612. function monthNameToNum(s) {
  613. s = ("" + s).trim().toLowerCase();
  614. var i = MONTH_NAMES.indexOf(s);
  615. if (i >= 0) {
  616. return i + 1;
  617. }
  618. i = MONTH_ABBR.indexOf(s);
  619. if (i >= 0) {
  620. return i + 1;
  621. }
  622. var n = parseInt(s);
  623. if (!isNaN(n) && n >= 1 && n <= 12) {
  624. return n;
  625. }
  626. return null;
  627. }
  628. // Month is a calendar-month filter (matches across all years). Stored as a list
  629. // so several months can be OR-ed together (handy with a tags input).
  630. function pushMonth(filter, mnum) {
  631. if (!mnum || mnum < 1 || mnum > 12) {
  632. return;
  633. }
  634. if (!filter.month) {
  635. filter.month = [];
  636. }
  637. if (filter.month.indexOf(mnum) < 0) {
  638. filter.month.push(mnum);
  639. }
  640. }
  641. // Every category is a list. Within a category the values are OR-ed; across
  642. // categories they are AND-ed (see buildWhere). `raw` is the single boolean
  643. // exception (a shorthand that expands into the RAW extensions).
  644. function newFilter() {
  645. return {
  646. text: [], filename: [], ext: [], raw: false,
  647. model: [], make: [], lens: [], orientation: [], month: [],
  648. iso: [], aperture: [], focal: [], mp: [], width: [], height: [],
  649. taken: [], modified: [], rating: []
  650. };
  651. }
  652. // Split a query string into tokens, honouring `key:"quoted value"` and "quoted".
  653. function tokenizeQuery(q) {
  654. var tokens = [];
  655. var re = /(\w+):"([^"]*)"|"([^"]*)"|(\S+)/g;
  656. var m;
  657. while ((m = re.exec(q)) !== null) {
  658. if (m[1] !== undefined) {
  659. tokens.push(m[1] + ":" + m[2]);
  660. } else if (m[3] !== undefined) {
  661. tokens.push(m[3]);
  662. } else {
  663. tokens.push(m[4]);
  664. }
  665. }
  666. return tokens;
  667. }
  668. function classifyToken(filter, token) {
  669. var lower = token.toLowerCase();
  670. var colon = token.indexOf(":");
  671. var key = "";
  672. var val = "";
  673. if (colon > 0) {
  674. key = token.substring(0, colon).toLowerCase();
  675. val = token.substring(colon + 1);
  676. }
  677. // f/2.8 or f2.8 (aperture shorthand)
  678. var fm = lower.match(/^f\/?(\d+(?:\.\d+)?)$/);
  679. if (colon < 0 && fm) {
  680. pushRange(filter, "aperture", { min: parseFloat(fm[1]), max: parseFloat(fm[1]) });
  681. return;
  682. }
  683. // 50mm (focal length shorthand)
  684. var fmm = lower.match(/^(\d+(?:\.\d+)?)mm$/);
  685. if (colon < 0 && fmm) {
  686. pushRange(filter, "focal", { min: parseFloat(fmm[1]), max: parseFloat(fmm[1]) });
  687. return;
  688. }
  689. if (colon < 0 && lower.charAt(0) === ".") {
  690. filter.ext.push(lower.substring(1));
  691. return;
  692. }
  693. if (colon < 0 && IMAGE_EXTENSIONS.indexOf(lower) >= 0) {
  694. filter.ext.push(lower);
  695. return;
  696. }
  697. if (colon < 0 && lower === "raw") {
  698. filter.raw = true;
  699. return;
  700. }
  701. if (colon < 0 && (lower === "landscape" || lower === "portrait" || lower === "square")) {
  702. filter.orientation.push(lower);
  703. return;
  704. }
  705. if (colon < 0 && isMonthName(lower)) {
  706. pushMonth(filter, monthNameToNum(lower));
  707. return;
  708. }
  709. if (colon < 0 && /^\d{4}$/.test(lower)) {
  710. pushDate(filter, "taken", parseDateRange(lower));
  711. return;
  712. }
  713. if (colon > 0) {
  714. switch (key) {
  715. case "iso":
  716. pushRange(filter, "iso", parseRange(val));
  717. return;
  718. case "rating":
  719. case "stars":
  720. case "star":
  721. pushRange(filter, "rating", parseRange(val.replace(/\*/g, "")));
  722. return;
  723. case "f":
  724. case "aperture":
  725. case "fnumber":
  726. pushRange(filter, "aperture", parseRange(val.replace(/^\//, "")));
  727. return;
  728. case "focal":
  729. case "fl":
  730. pushRange(filter, "focal", parseRange(val.replace(/mm$/i, "")));
  731. return;
  732. case "mp":
  733. case "megapixels":
  734. pushRange(filter, "mp", parseRange(val));
  735. return;
  736. case "width":
  737. case "w":
  738. pushRange(filter, "width", parseRange(val));
  739. return;
  740. case "height":
  741. case "h":
  742. pushRange(filter, "height", parseRange(val));
  743. return;
  744. case "model":
  745. case "camera":
  746. filter.model.push(val);
  747. return;
  748. case "make":
  749. case "brand":
  750. filter.make.push(val);
  751. return;
  752. case "lens":
  753. filter.lens.push(val);
  754. return;
  755. case "ext":
  756. case "type":
  757. filter.ext.push(val.toLowerCase().replace(/^\./, ""));
  758. return;
  759. case "name":
  760. case "filename":
  761. filter.filename.push(val);
  762. return;
  763. case "orientation":
  764. filter.orientation.push(val.toLowerCase());
  765. return;
  766. case "month":
  767. var monthVals = val.split(",");
  768. for (var mvi = 0; mvi < monthVals.length; mvi++) {
  769. pushMonth(filter, monthNameToNum(monthVals[mvi]));
  770. }
  771. return;
  772. case "taken":
  773. case "date":
  774. case "year":
  775. pushDate(filter, "taken", parseDateRange(val));
  776. return;
  777. case "modified":
  778. case "mtime":
  779. pushDate(filter, "modified", parseDateRange(val));
  780. return;
  781. case "before":
  782. pushDate(filter, "taken", { min: null, max: parseDateToUnix(val, true) });
  783. return;
  784. case "after":
  785. pushDate(filter, "taken", { min: parseDateToUnix(val, false), max: null });
  786. return;
  787. default:
  788. filter.text.push(token);
  789. return;
  790. }
  791. }
  792. // Plain free-text term.
  793. filter.text.push(token);
  794. }
  795. // Parse a free-text query string into a structured filter object.
  796. function parseSearchQuery(q) {
  797. var filter = newFilter();
  798. if (!q) {
  799. return filter;
  800. }
  801. var tokens = tokenizeQuery("" + q);
  802. for (var i = 0; i < tokens.length; i++) {
  803. classifyToken(filter, tokens[i]);
  804. }
  805. return filter;
  806. }
  807. // Merge an explicit structured filter object (from the UI) onto a parsed one.
  808. // String fields accept a single value or an array (OR-ed within the category).
  809. function applyExplicitFilters(filter, f) {
  810. if (!f || typeof f !== "object") {
  811. return;
  812. }
  813. function pushStrings(field, v) {
  814. if (v === undefined || v === null) {
  815. return;
  816. }
  817. var list = Array.isArray(v) ? v : [v];
  818. for (var k = 0; k < list.length; k++) {
  819. filter[field].push("" + list[k]);
  820. }
  821. }
  822. pushStrings("filename", f.filename);
  823. pushStrings("model", f.model);
  824. pushStrings("make", f.make);
  825. pushStrings("lens", f.lens);
  826. if (f.orientation) {
  827. var orients = Array.isArray(f.orientation) ? f.orientation : [f.orientation];
  828. for (var o = 0; o < orients.length; o++) {
  829. filter.orientation.push(("" + orients[o]).toLowerCase());
  830. }
  831. }
  832. if (f.raw) {
  833. filter.raw = true;
  834. }
  835. if (Array.isArray(f.ext)) {
  836. for (var i = 0; i < f.ext.length; i++) {
  837. filter.ext.push(("" + f.ext[i]).toLowerCase().replace(/^\./, ""));
  838. }
  839. }
  840. var ranges = ["iso", "aperture", "focal", "mp", "width", "height", "rating"];
  841. for (var r = 0; r < ranges.length; r++) {
  842. var rv = f[ranges[r]];
  843. if (rv) {
  844. var rlist = Array.isArray(rv) ? rv : [rv];
  845. for (var ri = 0; ri < rlist.length; ri++) {
  846. pushRange(filter, ranges[r], rlist[ri]);
  847. }
  848. }
  849. }
  850. if (f.taken) {
  851. var tlist = Array.isArray(f.taken) ? f.taken : [f.taken];
  852. for (var ti = 0; ti < tlist.length; ti++) {
  853. pushDate(filter, "taken", tlist[ti]);
  854. }
  855. }
  856. if (f.modified) {
  857. var mlist = Array.isArray(f.modified) ? f.modified : [f.modified];
  858. for (var mi = 0; mi < mlist.length; mi++) {
  859. pushDate(filter, "modified", mlist[mi]);
  860. }
  861. }
  862. if (f.month) {
  863. var fmonths = Array.isArray(f.month) ? f.month : [f.month];
  864. for (var fmi = 0; fmi < fmonths.length; fmi++) {
  865. pushMonth(filter, monthNameToNum(fmonths[fmi]));
  866. }
  867. }
  868. }
  869. // Wrap a list of OR-ed fragments as a single AND clause (parenthesised if >1).
  870. function pushOrGroup(clauses, fragments) {
  871. if (!fragments.length) {
  872. return;
  873. }
  874. clauses.push(fragments.length > 1 ? "(" + fragments.join(" OR ") + ")" : fragments[0]);
  875. }
  876. // OR group of LIKE conditions on a NOT NULL column (e.g. filename_lc).
  877. function addLikeGroup(clauses, args, col, list) {
  878. if (!list || !list.length) {
  879. return;
  880. }
  881. var ors = [];
  882. for (var i = 0; i < list.length; i++) {
  883. ors.push(col + " LIKE ?");
  884. args.push("%" + ("" + list[i]).toLowerCase() + "%");
  885. }
  886. pushOrGroup(clauses, ors);
  887. }
  888. // OR group of LIKE conditions on a nullable column.
  889. function addNullableLikeGroup(clauses, args, col, list) {
  890. if (!list || !list.length) {
  891. return;
  892. }
  893. var ors = [];
  894. for (var i = 0; i < list.length; i++) {
  895. ors.push("LOWER(IFNULL(" + col + ",'')) LIKE ?");
  896. args.push("%" + ("" + list[i]).toLowerCase() + "%");
  897. }
  898. pushOrGroup(clauses, ors);
  899. }
  900. // Equality OR group expressed as IN (...).
  901. function addInGroup(clauses, args, col, list) {
  902. if (!list || !list.length) {
  903. return;
  904. }
  905. var ph = [];
  906. for (var i = 0; i < list.length; i++) {
  907. ph.push("?");
  908. args.push(list[i]);
  909. }
  910. clauses.push(col + " IN (" + ph.join(",") + ")");
  911. }
  912. // OR group of numeric/date ranges. Each {min,max} becomes
  913. // "(col >= min AND col <= max)"; the ranges within one category are OR-ed.
  914. function addRangeGroup(clauses, args, col, list) {
  915. if (!list || !list.length) {
  916. return;
  917. }
  918. var ors = [];
  919. for (var i = 0; i < list.length; i++) {
  920. var r = list[i];
  921. if (!r) {
  922. continue;
  923. }
  924. var parts = [];
  925. if (r.min !== null && r.min !== undefined) {
  926. parts.push(col + " >= ?");
  927. args.push(r.min);
  928. }
  929. if (r.max !== null && r.max !== undefined) {
  930. parts.push(col + " <= ?");
  931. args.push(r.max);
  932. }
  933. if (!parts.length) {
  934. continue;
  935. }
  936. ors.push(parts.length > 1 ? "(" + parts.join(" AND ") + ")" : parts[0]);
  937. }
  938. pushOrGroup(clauses, ors);
  939. }
  940. // Build the parameterised WHERE clause + args for a structured filter.
  941. // Within a category multiple values are OR-ed; categories are AND-ed together.
  942. function buildWhere(filter) {
  943. var clauses = [];
  944. var args = [];
  945. var i;
  946. // Free text: OR across terms, each term matching any of several columns.
  947. if (filter.text && filter.text.length) {
  948. var textOrs = [];
  949. for (i = 0; i < filter.text.length; i++) {
  950. var term = "%" + filter.text[i].toLowerCase() + "%";
  951. textOrs.push("(filename_lc LIKE ? OR LOWER(IFNULL(camera_model,'')) LIKE ?" +
  952. " OR LOWER(IFNULL(lens_model,'')) LIKE ? OR LOWER(IFNULL(camera_make,'')) LIKE ?)");
  953. args.push(term, term, term, term);
  954. }
  955. pushOrGroup(clauses, textOrs);
  956. }
  957. addLikeGroup(clauses, args, "filename_lc", filter.filename);
  958. addNullableLikeGroup(clauses, args, "camera_model", filter.model);
  959. addNullableLikeGroup(clauses, args, "camera_make", filter.make);
  960. addNullableLikeGroup(clauses, args, "lens_model", filter.lens);
  961. addInGroup(clauses, args, "orientation", filter.orientation);
  962. // Extensions (plus the RAW shorthand) are all OR-ed through a single IN.
  963. var extList = (filter.ext || []).slice();
  964. if (filter.raw) {
  965. for (i = 0; i < RAW_EXTENSIONS.length; i++) {
  966. if (extList.indexOf(RAW_EXTENSIONS[i]) < 0) {
  967. extList.push(RAW_EXTENSIONS[i]);
  968. }
  969. }
  970. }
  971. addInGroup(clauses, args, "ext", extList);
  972. // Calendar months (matched across all years), OR-ed via IN.
  973. if (filter.month && filter.month.length) {
  974. var mph = [];
  975. for (i = 0; i < filter.month.length; i++) {
  976. mph.push("?");
  977. args.push(filter.month[i]);
  978. }
  979. clauses.push("CAST(strftime('%m', taken_date, 'unixepoch') AS INTEGER) IN (" + mph.join(",") + ")");
  980. }
  981. addRangeGroup(clauses, args, "iso", filter.iso);
  982. addRangeGroup(clauses, args, "aperture", filter.aperture);
  983. addRangeGroup(clauses, args, "focal_length", filter.focal);
  984. addRangeGroup(clauses, args, "megapixels", filter.mp);
  985. addRangeGroup(clauses, args, "width", filter.width);
  986. addRangeGroup(clauses, args, "height", filter.height);
  987. addRangeGroup(clauses, args, "taken_date", filter.taken);
  988. addRangeGroup(clauses, args, "modified_date", filter.modified);
  989. // Rating lives in the joined photo_ratings table; unrated photos count as 0.
  990. addRangeGroup(clauses, args, "IFNULL(photo_ratings.rating, 0)", filter.rating);
  991. return { clause: clauses.length ? clauses.join(" AND ") : "1=1", args: args };
  992. }
  993. function buildOrderBy(sort) {
  994. switch (sort) {
  995. case "taken_asc":
  996. return "taken_date ASC";
  997. case "taken_desc":
  998. return "taken_date DESC";
  999. case "modified_asc":
  1000. return "modified_date ASC";
  1001. case "modified_desc":
  1002. return "modified_date DESC";
  1003. case "name_asc":
  1004. return "filename_lc ASC";
  1005. case "name_desc":
  1006. return "filename_lc DESC";
  1007. case "size_asc":
  1008. return "filesize ASC";
  1009. case "size_desc":
  1010. return "filesize DESC";
  1011. case "mp_desc":
  1012. return "megapixels DESC";
  1013. case "mp_asc":
  1014. return "megapixels ASC";
  1015. default:
  1016. return "taken_date DESC";
  1017. }
  1018. }