Frontend: Compact verbose comments in model/ modules #4966

This commit is contained in:
Michael Mayer 2026-05-15 17:35:33 +00:00
parent f89a0922a3
commit 069b3cd87b
3 changed files with 91 additions and 283 deletions

View file

@ -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;
}

View file

@ -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;

View file

@ -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 "";