diff --git a/frontend/src/model/model-cache.js b/frontend/src/model/model-cache.js index 5f07a24b0..40472cfd9 100644 --- a/frontend/src/model/model-cache.js +++ b/frontend/src/model/model-cache.js @@ -23,9 +23,7 @@ Additional information can be found in our Developer Guide: */ -// Deep-clones a plain object via JSON. Used at both ends of the cache -// lifecycle (set + hydrate) so callers can never share refs with cached -// values (cache isolation contract). +// deepClone returns a JSON deep copy so cached values never share refs with callers. function deepClone(value) { if (value === null || typeof value !== "object") { return value; @@ -33,12 +31,8 @@ function deepClone(value) { return JSON.parse(JSON.stringify(value)); } -// ModelCacheStaleFetchError is raised by fetch() when clear() bumps -// the session-epoch counter while the loader is still in flight. It -// signals "the cache state this fetch belonged to is gone — discard -// the result." Existing callers absorb it via their .catch handlers; -// the export lets future callers discriminate this from real loader -// failures (network, auth, etc.) if they need to. +// ModelCacheStaleFetchError signals that a fetch() result was discarded because +// clear() bumped the session-epoch counter while the loader was still in flight. export class ModelCacheStaleFetchError extends Error { constructor(key) { super(`ModelCache: discarded stale fetch for "${key}" after clear()`); @@ -47,34 +41,17 @@ export class ModelCacheStaleFetchError extends Error { } } -// ModelCache is a small in-memory LRU for full-entity model snapshots. It -// is model-layer infrastructure: a subclass (e.g. Photo) supplies snapshot -// and hydrate hooks so the cache can stay neutral about model shape. -// +// ModelCache is a small in-memory LRU for full-entity model snapshots; a +// subclass supplies snapshot/hydrate hooks so the cache stays shape-neutral. // Contract: -// - Stores plain value snapshots, never live model instances. -// - Returns a fresh hydrated instance for every cache hit so callers -// can mutate freely without aliasing the cached source of truth. -// - Coalesces concurrent fetch() calls for the same key behind one -// in-flight Promise, then hydrates each waiter independently. -// - LRU ordering: every read or refresh moves the entry to the most- -// recent slot; size is capped at `max` and the oldest entry is -// evicted when the cap is exceeded. -// - Optional TTL (`ttl` ms). 0 / null / negative means "disabled" — -// the recommended default for full-entity caches that lean on the -// websocket update channel for freshness. -// - clear() empties items and the pending-request map AND bumps an -// internal session-epoch counter. In-flight loader Promises are -// not literally aborted (we don't thread an AbortController -// through every loader), but every fetch records the epoch at -// start and a fetch whose epoch no longer matches REJECTS with -// ModelCacheStaleFetchError instead of resolving. Net effect: a -// logout-then-relogin sequence cannot serve data that was fetched -// under the previous role — neither into the cache, nor through -// a waiter's .then handler into UI state. Existing callers absorb -// the rejection via their .catch handlers. Synchronous mutators -// (set / refreshIfPresent) are not gated — they have no async -// window to race against. +// - Stores plain snapshot values, never live model instances. +// - Returns a fresh hydrated instance per cache hit (callers may mutate freely). +// - Coalesces concurrent fetch() calls for the same key onto one in-flight Promise. +// - LRU promotion on read/refresh; capped at `max`, oldest evicted on overflow. +// - Optional TTL (`ttl` ms; 0/null/negative disables) for full-entity caches. +// - clear() empties the cache and bumps an epoch counter so in-flight fetches +// that started under the previous epoch REJECT with ModelCacheStaleFetchError +// instead of leaking data across a logout/relogin boundary. export class ModelCache { // Constructs a ModelCache with the given options: // - max: hard size cap; oldest entry is evicted on overflow. @@ -96,19 +73,12 @@ export class ModelCache { this.now = now; this.items = new Map(); this.pending = new Map(); - // Monotonic counter bumped on clear(). fetch() and the direct - // mutators capture this value at their entry point and compare - // it before mutating items, so a Promise that started under epoch - // N can never write into the cache after clear() advanced it to - // N+1. See class-level docs. + // Monotonic counter bumped on clear(); see class-level docs. this._epoch = 0; } - // Returns true if the key has a live (non-expired) entry. Lazy-prunes - // an expired entry before reporting absence so size() / refreshIfPresent() - // / oldest-eviction-on-overflow stay consistent with what readers can - // actually retrieve. Does NOT change LRU ordering for live entries — - // has() is a probe, not a touch. + // has returns true if the key has a live (non-expired) entry; expired entries + // are pruned before reporting absence. Probe only — does not promote LRU order. has(key) { if (!this.items.has(key)) { return false; @@ -121,10 +91,8 @@ export class ModelCache { return true; } - // Returns a freshly hydrated model for the given key, or null on miss - // or expiration. Touching the entry promotes it to the most-recent LRU - // slot. Hydration always happens against a deep clone of the snapshot - // so the returned instance can be safely mutated. + // get returns a freshly hydrated model for the key (or null on miss/expiry), + // promoting the entry to the most-recent LRU slot. get(key) { const entry = this.items.get(key); if (!entry) { @@ -140,15 +108,9 @@ export class ModelCache { return this.hydrate(deepClone(entry.value)); } - // Stores or refreshes the entry for `key`. The argument is routed - // through the configured snapshot() callback so the cache always - // holds normalized snapshot values, never live model instances — - // even when callers pass models directly. The snapshot is also - // deep-cloned before being stored so future caller-side mutations - // on `value` cannot bleed into the cache. LRU ordering and size - // cap are enforced. snapshot() must be idempotent for already- - // snapshotted plain values; the Photo snapshot satisfies that - // ("photo instanceof Photo ? photo.getValues() : photo"). + // set stores or refreshes the entry for `key`, routing the value through + // snapshot() and a deep clone so the cache holds normalized plain values. + // snapshot() must be idempotent for already-snapshotted inputs. set(key, value) { if (this.max <= 0) { return; @@ -157,12 +119,7 @@ export class ModelCache { if (this.items.has(key)) { this.items.delete(key); } else if (this.items.size >= this.max) { - // Reclaim every expired slot before evicting a live entry. - // Without this pass an expired ghost can hold a slot until - // overflow churn happens to evict it — and under non-uniform - // LRU promotion a fresh entry can even get evicted ahead of - // a stale one. The pass is O(n) but only runs at the cap and - // is a no-op when ttl <= 0 (the Photo default). + // Reclaim expired slots before evicting a live entry; no-op when ttl <= 0. if (this.ttl > 0) { const cutoff = this.now(); for (const [k, entry] of this.items) { @@ -182,21 +139,10 @@ export class ModelCache { }); } - // Returns a hydrated model for `key`, fetching via `loader` on miss. - // Concurrent fetches for the same key share a single in-flight Promise; - // each waiter receives an isolated hydrated instance. The loader is - // expected to return a model whose values flow through `snapshot`. - // - // Epoch gate: the entry epoch is captured before the loader runs. - // If clear() bumps the epoch while the loader is still in flight, - // the returned Promise REJECTS with ModelCacheStaleFetchError so - // waiters' .then handlers never see the stale value — caller-side - // .catch() handlers absorb the rejection. The cache stays empty; - // the next read goes back through the loader under the new epoch. - // Rejecting (rather than resolving with the value) is what makes - // the post-logout guarantee airtight: lightbox.vue / dialog.vue - // both already chain a .catch(), so a stale role-A fetch can't - // sneak data into UI mounted under role B. + // fetch returns a hydrated model for `key`, calling `loader` on miss and + // sharing one in-flight Promise across concurrent waiters. If clear() bumps + // the epoch mid-flight the returned Promise rejects with ModelCacheStaleFetchError + // so stale role-A data cannot leak into role-B UI after a logout/relogin. fetch(key, loader) { const cached = this.get(key); if (cached) { @@ -210,13 +156,7 @@ export class ModelCache { .then(loader) .then((model) => { if (this._epoch !== epoch) { - // clear() bumped the epoch while this loader was in - // flight. Reject so the original waiter's .then doesn't - // fire — otherwise a logout-then-relogin window could - // route role-A data into role-B's UI before the route - // change unmounts the component. set() will re-snapshot; - // the snapshot callback is required to be idempotent for - // plain snapshot values. + // clear() advanced the epoch mid-flight — drop the stale result. throw new ModelCacheStaleFetchError(key); } const snapshot = this.snapshot(model); @@ -224,9 +164,8 @@ export class ModelCache { return snapshot; }) .finally(() => { - // Only forget the pending entry if it still belongs to this - // fetch. clear() already wiped pending; a new fetch on the - // same key under the next epoch may have re-seeded it. + // Only forget the pending entry if it still belongs to this fetch — + // a post-clear() refetch on the same key may have re-seeded it. if (this.pending.get(key) === request) { this.pending.delete(key); } @@ -235,15 +174,8 @@ export class ModelCache { return request.then((snapshot) => this.hydrate(deepClone(snapshot))); } - // Refreshes an entry only if it is already cached AND still live. - // Used by background event handlers (e.g. websocket "updated" - // payloads) so the cache doesn't grow from traffic the user hasn't - // actively browsed. The expired-entry probe goes through has() so - // an entry past its TTL is pruned and reported as absent rather - // than silently revived under a fresh expiry. The value is routed - // through snapshot() via set() so callers can pass models or - // pre-snapshotted plain values interchangeably. Returns true when - // an entry was refreshed, false otherwise. + // refreshIfPresent updates an entry only if it is already cached and live, + // so background event handlers don't grow the cache with un-browsed keys. refreshIfPresent(key, value) { if (!this.has(key)) { return false; @@ -258,23 +190,16 @@ export class ModelCache { this.pending.delete(key); } - // Empties the cache and the pending-request map, then bumps the - // session-epoch counter. In-flight loader Promises are not literally - // aborted, but a fetch that started under the previous epoch will - // see the bump in its .then handler and skip the cache write — so - // a logout-then-relogin sequence cannot leak data fetched under the - // previous role even though its loader resolves after clear(). + // clear empties the cache and the pending-request map and bumps the epoch + // counter so in-flight fetches under the previous epoch reject instead of writing. clear() { this.items.clear(); this.pending.clear(); this._epoch++; } - // Returns the current entry count. With TTL enabled this is a coarse - // upper bound — expired entries are lazy-pruned on has() / get() / - // refreshIfPresent(), not by a sweeper. For tests and debug counters - // call has() (or query items.size after a representative read pass) - // when an exact live count matters. + // size returns the entry count; with TTL enabled this is a coarse upper + // bound — expired entries are lazy-pruned on read, not by a sweeper. size() { return this.items.size; } diff --git a/frontend/src/model/photo.js b/frontend/src/model/photo.js index 0b3dd8709..709eed553 100644 --- a/frontend/src/model/photo.js +++ b/frontend/src/model/photo.js @@ -24,13 +24,8 @@ export const TimeZoneLocal = "Local"; export let BatchSize = 156; -// MaxLength mirrors the backend VARCHAR caps so UI validation matches -// what the server will actually persist. Keep in sync with the GORM -// struct tags in internal/entity/photo.go (PhotoTitle, PhotoCaption) -// and internal/entity/details.go (Subject, Artist, Copyright, License, -// Keywords, Notes). The Set* helpers in details.go further clip via -// txt.ClipShortText (1024) / txt.ClipText (2048); these caps mirror -// that ceiling, not the looser raw VARCHAR length. +// MaxLength mirrors the backend Set*-helper clips (txt.ClipShortText / ClipText) +// so UI validation matches what the server persists; keep in sync with details.go. export const MaxLength = Object.freeze({ Title: 200, Caption: 4096, @@ -1124,15 +1119,9 @@ export class Photo extends RestModel { return $gettext("Unknown"); } - // Moves this photo to the archive (soft delete). Intentionally - // does NOT flip a local Archived flag the way Thumb.archive() - // does — no Photo consumer reads `photo.Archived` (the lightbox's - // `this.model?.Archived` checks all run against a Thumb), so an - // optimistic flip on Photo would just add dead state. The actual - // UI update for the photo-grid caller (view/cards.vue) is driven - // by the `photos.archived` WS event handler in page/photos.vue - // around line 802, which calls removeResult() to drop the row - // from the search results outside the Archive context. + // archive moves the photo to the archive (soft delete). No local flag flip: + // Photo consumers don't read .Archived (Thumb carries that state); the grid + // refreshes via the photos.archived WS handler. archive() { return $api.post("batch/photos/archive", { photos: [this.getId()] }); } @@ -1216,20 +1205,10 @@ export class Photo extends RestModel { return $api.delete(this.getEntityResource() + "/label/" + id).then((r) => Promise.resolve(this.setValues(r.data))); } - // Adds this photo to the given album. The album-membership endpoint - // returns only an ack, so on success the LRU cache is evicted and the - // photo is refetched so this.Albums reflects the saved state. Without - // the explicit refind, callers would have to wait for an albums.updated - // websocket round-trip — and the WS handler in this file evicts on - // photos.* channels, not albums.*. - // - // The name overlaps with Thumb.addToAlbum/removeFromAlbum on purpose: - // those operate at the photo-grid layer (toggling a Removed flag on a - // single thumbnail for lightbox menu visibility — see lightbox.vue - // onRemoveFromAlbum). The Photo-level methods here update this.Albums - // for sidebar chip rendering. Different layers, different semantics — - // both contracts are pinned in their respective tests so a future - // "consolidation" PR doesn't accidentally collapse them. + // addToAlbum adds this photo to the album, then evicts and refetches so + // this.Albums reflects the saved state without waiting on a WS round-trip. + // Distinct from Thumb.addToAlbum (grid layer, Removed flag); both contracts + // are pinned in tests. addToAlbum(albumUID) { if (!albumUID) return Promise.resolve(this); return $api @@ -1241,8 +1220,7 @@ export class Photo extends RestModel { .then((photo) => Promise.resolve(this.setValues(photo.getValues()))); } - // Removes this photo from the given album. Mirrors addToAlbum's - // evict + refind pattern — see that method for rationale. + // removeFromAlbum mirrors addToAlbum's evict + refind pattern. removeFromAlbum(albumUID) { if (!albumUID) return Promise.resolve(this); return $api @@ -1375,26 +1353,17 @@ export class Photo extends RestModel { return Photo._cache; } - // Removes a photo from the LRU cache. Mutating methods on this model - // no longer call this directly: the backend publishes "photos.updated" - // / "photos.deleted" / "photos.archived" / "photos.restored" via - // websocket and the cache is evicted from there (see the module-level - // subscriptions below). This stays public as an escape hatch for flows - // that mutate a photo without firing one of those events — currently - // album-membership changes, which only emit "albums.updated". + // evictCache drops a photo from the LRU. Mutating methods rely on the + // photos.* WS subscriptions below; this stays as an escape hatch for flows + // that mutate a photo without a matching event (e.g. album-membership). static evictCache(uid) { if (uid) { Photo._cache.evict(uid); } } - // Drops every cached photo and any in-flight request. Called on session - // reset so metadata fetched under one role cannot be served to another. - // ModelCache.clear() bumps an internal session-epoch counter and any - // in-flight fetch whose epoch no longer matches REJECTS with - // ModelCacheStaleFetchError instead of resolving — so neither the - // cache nor a .then-chained UI assignment can leak role-A data into - // role B during the post-logout unmount window. + // clearCache drops every cached photo and rejects in-flight fetches via the + // session-epoch gate so metadata fetched under one role cannot reach another. static clearCache() { Photo._cache.clear(); } @@ -1438,24 +1407,10 @@ export class Photo extends RestModel { } } -// Drops cached entries from the WS event payload. The backend uses -// two different shapes on the same channel family — handle both: -// -// - "photos.updated" (PublishPhotoEvent in internal/api/api_event.go) -// emits a search.Photos result: an array of objects with .UID. -// - "photos.archived" / "photos.restored" / "photos.deleted" / -// "photos.edited" (EntitiesArchived / EntitiesRestored / -// EntitiesDeleted / EntitiesEdited in internal/event/ -// publish_entities.go) emit a []string of bare UIDs. -// -// A single helper covers every shape so we don't have to keep them -// in sync. The "photos.updated" payload is consumed as an EVICT -// signal (not a refresh) because search.Photos flattens nested -// fields like Details into top-level columns (DetailsKeywords, -// DetailsSubject, ...); hydrating from that snapshot would leave -// Photo.Details === undefined and collapse the sidebar's isEditable -// computed. Eviction sends the next read back to find() and the -// field-complete /photos/:uid endpoint. +// evictCachedFromEntities drops cached entries from a WS payload, accepting +// both bare-UID arrays and search.Photos result objects. Treat photos.updated +// as evict-only — its flattened search-result shape would collapse Photo.Details +// on hydrate; the next read goes back through /photos/:uid for the full record. function evictCachedFromEntities(data) { if (!data || !Array.isArray(data.entities)) { return; @@ -1469,16 +1424,7 @@ function evictCachedFromEntities(data) { }); } -// One hierarchical subscriber on the photos namespace, filtered to -// the standard mutation verbs by subscribeEntityActions. Mirrors the -// page/photos.vue onUpdate switch pattern at the cache layer: a -// future verb (e.g. a hypothetical "merged") joins via one edit to -// ENTITY_MUTATIONS in common/event.js, and non-mutation channels on -// the same namespace stay no-ops without needing per-channel guards -// here. Also covers "created" from the indexer (index_mediafile.go) -// and unstack (photo_unstack.go) — harmless no-op today since -// brand-new UIDs are never cached, but future-proofs scenarios -// where a recreated UID needs the stale entry dropped. +// Evict cache entries on any standard mutation verb in the photos namespace. subscribeEntityActions("photos", (_ev, data) => evictCachedFromEntities(data)); export default Photo; diff --git a/frontend/src/model/thumb.js b/frontend/src/model/thumb.js index 98b9f9135..35a936205 100644 --- a/frontend/src/model/thumb.js +++ b/frontend/src/model/thumb.js @@ -71,14 +71,8 @@ export class Thumb extends Model { } } - // Resolves to the full Photo entity for this slide, fetched via - // the shared LRU cache. Each call returns a fresh hydrated - // instance so consumers can mutate locally without aliasing the - // cached snapshot. Resolves to an empty Photo placeholder when - // this thumb has no UID. Lightbox uses this to load sidebar - // metadata for the current slide and to warm neighbours via - // Photo.prefetchAround. Rejections (e.g. ModelCacheStaleFetchError - // after a logout-clear) propagate to callers' .catch handlers. + // loadPhoto resolves to the full Photo entity for this slide via the LRU + // cache, or an empty Photo placeholder when this thumb has no UID. loadPhoto() { if (!this.UID) { return Promise.resolve(new Photo()); @@ -86,34 +80,17 @@ export class Thumb extends Model { return Photo.findCached(this.UID); } - // Drops the cached Photo entity for this slide so the next - // loadPhoto() rehydrates from GET /photos/:uid. Used by flows - // that mutate the photo without firing a photos.* WS event the - // cache subscribes to (currently: album-membership changes, - // which only publish albums.updated). archive / restore / delete - // already auto-evict via the photo.js WS subscribers, so call - // sites for those don't need this. + // evictPhoto drops the cached Photo for this slide so the next loadPhoto() + // rehydrates from /photos/:uid. Use for mutations without a photos.* WS event. evictPhoto() { if (this.UID) { Photo.evictCache(this.UID); } } - // Moves this photo to the archive (soft delete) and flips the - // local Archived flag immediately so menu buttons re-render - // without waiting for the API round-trip. The pre-call value of - // Archived is captured in a closure and restored on rejection — - // a literal `false` rollback would be wrong if the field was - // undefined (defaults aren't declared) or already true (no-op - // re-archive). Resolves on success; the backend publishes - // photos.archived which the photo cache subscribes to for - // automatic eviction. Mirrors the batch-photos endpoint that - // view/cards.vue already targets via Photo.prototype.archive — - // kept on Thumb for the lightbox flow which holds a Thumb (not a - // Photo) for the current slide. Rapid-fire prevention is the - // caller's responsibility; if a UI flow needs it, it can add - // `blockUI("busy")` / `unblockUI()` or a similar guard at the - // handler layer without making the model stateful. + // archive moves the photo to the archive (soft delete) and optimistically + // flips Archived so menu buttons re-render before the round-trip; rollback + // restores the captured prior value (not a literal false) on rejection. archive() { const prev = this.Archived; this.Archived = true; @@ -152,10 +129,8 @@ export class Thumb extends Model { }); } - // Formats Lat/Lng as an EXIF-style coordinate pair separated by - // an en-space (U+2002) — see the literal in the body. Returns a - // 0/0 placeholder when coordinates are missing so the sidebar - // EXIF row doesn't collapse. + // getLatLng formats Lat/Lng as EXIF-style coordinates (en-space separator), + // returning a 0/0 placeholder when coordinates are missing so the row holds. getLatLng() { if (!this.Lat || !this.Lng) { return `0°N\u20030°E`; @@ -164,12 +139,8 @@ export class Thumb extends Model { return `${this.Lat.toFixed(5)}°N\u2002${this.Lng.toFixed(5)}°E`; } - // Formats Lat/Lng as a compact, space-separated pair rounded to 4 - // fractional digits (~11 m precision). Used by the lightbox sidebar's - // combined Location row where the available width is tighter than the - // EXIF row's; the rounded form matches typical GPS quality and keeps - // the subtitle on one line. Returns an empty string when coordinates - // are missing. + // getLatLngShort formats Lat/Lng rounded to ~11 m precision for the sidebar + // Location row; returns an empty string when coordinates are missing. getLatLngShort() { if (!this.Lat || !this.Lng) { return ""; @@ -178,23 +149,17 @@ export class Thumb extends Model { return `${this.Lat.toFixed(4)}°N\u2002${this.Lng.toFixed(4)}°E`; } - // Copies Lat/Lng to the system clipboard as `lat,lng` decimals so - // they paste cleanly into mapping tools. No-ops when coordinates - // are missing rather than copying a misleading "0,0". + // copyLatLng copies coordinates to the clipboard as `lat,lng` decimals; + // no-op when coordinates are missing (avoid pasting a misleading "0,0"). copyLatLng() { - // Abort if latitude or longitude are not set. if (!this.Lat || !this.Lng) { return; } - - // Use the browser API to copy the coordinates to the clipboard. $util.copyText(`${this.Lat.toString()},${this.Lng.toString()}`); } - // Returns a rounded megapixel string (e.g. "12.0MP") for the - // type-info row. Returns the literal "0.0MP" — not 0 or empty — - // when dimensions are unknown; getTypeInfo() uses that sentinel - // to decide whether to skip the MP segment. + // getMegaPixels returns a rounded megapixel string (e.g. "12.0MP"); returns + // the literal "0.0MP" when dimensions are unknown — getTypeInfo skips on that. getMegaPixels() { if (!this.Width || !this.Height) { return "0.0MP"; @@ -203,9 +168,7 @@ export class Thumb extends Model { return `${((this.Width * this.Height) / 1000000).toFixed(1)}MP`; } - // Returns the Material Design icon name for this slide's media - // type, used by the lightbox type chip. Falls back to a generic - // image icon for unknown types. + // getTypeIcon returns the Material Design icon name for the type chip; falls back to mdi-image. getTypeIcon() { switch (this.Type) { case "raw": @@ -225,12 +188,9 @@ export class Thumb extends Model { } } - // Builds the EXIF-summary string shown next to the type chip - // (codec / megapixels / dimensions, joined by em-spaces). The - // segment order varies by media type so the most useful field - // leads — duration first for video, codec for raw, etc. Returns - // localized "Document" for documents and may return an empty - // string when nothing useful is known. + // getTypeInfo builds the codec/megapixels/dimensions summary next to the + // type chip. Segment order varies by media type so the most useful field + // leads (duration for video, codec for raw); may return an empty string. getTypeInfo() { let info = []; const mp = this.getMegaPixels(); @@ -301,12 +261,8 @@ export class Thumb extends Model { return info.join("\u2003"); } - // Returns a placeholder Thumb-shaped object for slides that can't - // be rendered (missing hash, deleted file, etc.). Every Thumbs - // entry points at the static 404 image so the lightbox grid stays - // visually consistent without throwing on missing assets. Returns - // a plain object (not a Thumb instance) — callers that need an - // instance wrap the result themselves. + // notFound returns a placeholder Thumb-shaped object for slides that can't + // be rendered (missing hash, deleted file); each Thumbs entry uses the 404 image. static notFound() { const result = { UID: "", @@ -342,9 +298,7 @@ export class Thumb extends Model { return result; } - // Builds a Thumb array from a Photos search response. Each photo - // flows through fromPhoto(), which picks the best available file - // for thumbnail rendering (RAW/Live preferred over JPEG). + // fromPhotos builds a Thumb array from a Photos search response via fromPhoto. static fromPhotos(photos) { let result = []; const n = photos.length; @@ -356,11 +310,8 @@ export class Thumb extends Model { return result; } - // Constructs a Thumb from a Photo entity, choosing the original - // file (RAW/Live preferred over JPEG via Photo.originalFile()) to - // source hash/dimensions/codec. Falls back to the Photo's own - // top-level fields when no Files are available. Returns a - // notFound() placeholder when neither hash nor files exist. + // fromPhoto builds a Thumb from a Photo entity using originalFile() (RAW/Live + // preferred over JPEG) and falling back to top-level fields when Files is empty. static fromPhoto(photo) { if (!photo || (!photo.Hash && !photo.Files?.length)) { return this.notFound(); @@ -421,11 +372,8 @@ export class Thumb extends Model { return new this(result); } - // Constructs a Thumb from a specific File belonging to a Photo — - // used by the file-list view to surface each file as its own - // slide. The Photo provides title / location / EXIF metadata; the - // File provides hash / dimensions / codec. Returns notFound() - // when any required input or hash is missing. + // fromFile builds a Thumb from a specific File of a Photo for the file-list view; + // the Photo provides metadata, the File provides hash/dimensions/codec. static fromFile(photo, file) { if (!photo || !file || !file.Hash) { return this.notFound(); @@ -466,17 +414,14 @@ export class Thumb extends Model { return new this(result); } - // Wraps an array of plain Thumb-shaped values as Thumb instances. - // Used by endpoints that already return Thumb-shaped JSON (e.g. - // /photos/view), bypassing the fromPhoto / fromFile mappers. + // wrap turns plain Thumb-shaped values into Thumb instances, bypassing the + // fromPhoto / fromFile mappers for endpoints that already return Thumb shape. static wrap(data) { return data.map((values) => new this(values)); } - // Like fromPhotos but expands each photo's Files[] into one Thumb - // per JPEG/PNG file — used by stack views where every variant - // should be its own slide. Skips photos without files and any - // file types other than jpg/png. + // fromFiles is like fromPhotos but expands each photo's Files[] into one + // Thumb per jpg/png file; used by stack views. Other file types are skipped. static fromFiles(photos) { let result = []; @@ -511,15 +456,10 @@ export class Thumb extends Model { return result; } - // Scales a file's actual dimensions to fit within a (width, - // height) box, preserving aspect ratio. Returns the file's - // native size unchanged when the box is already big enough so we - // don't upscale low-resolution thumbnails. Mirrors - // Photo.calculateSize but operates on a File rather than a Photo - // so file/edit views can size individual files. + // calculateSize scales a file's dimensions to fit within a (width, height) + // box, preserving aspect ratio; never upscales (returns native size when smaller). static calculateSize(file, width, height) { if (width >= file.Width && height >= file.Height) { - // Smaller return { width: file.Width, height: file.Height }; } @@ -539,10 +479,8 @@ export class Thumb extends Model { return { width: newW, height: newH }; } - // Builds the cached-thumbnail URL for a file at the given size - // (one of the keys from window.__CONFIG__.thumbs). Returns the - // static 404 image when the file has no hash so the lightbox grid - // still renders without throwing on broken assets. + // thumbnailUrl builds the cached-thumbnail URL for the given file and size; + // returns the static 404 image when the file has no hash. static thumbnailUrl(file, size) { if (!file.Hash) { return `${$config.staticUri}/img/404.jpg`; @@ -551,9 +489,8 @@ export class Thumb extends Model { return `${$config.contentUri}/t/${file.Hash}/${$config.previewToken}/${size}`; } - // Builds the original-file download URL for a file. Returns an - // empty string (not a "broken" URL) when the file has no hash so - // consumers can guard with truthiness. + // downloadUrl builds the original-file download URL; returns "" when the + // file has no hash so consumers can guard with truthiness. static downloadUrl(file) { if (!file || !file.Hash) { return "";