mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Frontend: Compact verbose comments in model/ modules #4966
This commit is contained in:
parent
f89a0922a3
commit
069b3cd87b
3 changed files with 91 additions and 283 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue