Files
papercrate/frontend/src/asset_manager.ts
T
2025-11-13 01:07:55 +01:00

500 lines
14 KiB
TypeScript

import type { AxiosInstance } from 'axios';
export type Identifier = string | number;
export interface AssetObject {
ordinal?: number;
url?: string | null;
metadata?: Record<string, unknown> | null;
expires_at?: number | null;
[key: string]: unknown;
}
export interface AssetLike {
id?: Identifier;
asset_type?: string;
cardinality?: number | null;
url?: string | null;
metadata?: Record<string, unknown> | null;
expiresAt?: number | null;
assets?: Record<string, AssetLike> | AssetLike[] | null;
objects?: AssetObject[] | null;
[key: string]: unknown;
}
export interface DocumentVersionLike {
assets?: Record<string, AssetLike> | AssetLike[] | null;
metadata?: Record<string, unknown> & { page_count?: number } | null;
[key: string]: unknown;
}
export interface DocumentLike {
id?: Identifier;
current_version?: DocumentVersionLike | null;
[key: string]: unknown;
}
export type EnsureAssetUrl = (
documentId: Identifier,
asset: AssetLike,
options?: { start?: number | null; limit?: number | null; force?: boolean; [key: string]: unknown },
) => Promise<unknown>;
export type GetAsset = (document: DocumentLike, assetType: string) => AssetLike | null | undefined;
export const getAssetFromGroup = (
assets: AssetLike[] | Record<string, AssetLike> | null | undefined,
assetType: string,
): AssetLike | null => {
if (!assetType || !assets) {
return null;
}
if (Array.isArray(assets)) {
return assets.find((entry) => entry?.asset_type === assetType) || null;
}
return assets?.[assetType] || null;
};
export const getAssetFromVersion = (currentVersion, assetType) => {
if (!currentVersion) {
return null;
}
return getAssetFromGroup(currentVersion.assets, assetType);
};
const normalizeAssetObjects = (objects?: AssetObject[] | null): AssetObject[] => {
if (!Array.isArray(objects)) {
return [];
}
return objects
.filter((entry) => Number.isInteger(entry?.ordinal))
.slice()
.sort((a, b) => a.ordinal - b.ordinal);
};
const mergeAssetObjects = (
existingObjects?: AssetObject[] | null,
incomingObjects?: AssetObject[] | null,
): AssetObject[] => {
const merged = new Map<number, AssetObject>();
normalizeAssetObjects(existingObjects).forEach((entry) => {
if (typeof entry.ordinal === 'number') {
merged.set(entry.ordinal, { ...entry });
}
});
normalizeAssetObjects(incomingObjects).forEach((entry) => {
if (typeof entry.ordinal === 'number') {
const current = merged.get(entry.ordinal) || {};
merged.set(entry.ordinal, { ...current, ...entry });
}
});
return Array.from(merged.entries())
.sort((a, b) => a[0] - b[0])
.map(([, value]) => value);
};
export class AssetView {
asset: AssetLike | null;
private _objectsRef: AssetObject[] | null;
private _sortedObjects: AssetObject[];
constructor(asset?: AssetLike | null) {
this.asset = asset || null;
this._objectsRef = null;
this._sortedObjects = [];
}
getCardinality(): number {
if (!this.asset) {
return 0;
}
const reported = Number(this.asset.cardinality);
if (Number.isFinite(reported) && reported > 0) {
return reported;
}
const objectsCount = this.getObjects().length;
if (objectsCount > 0) {
return objectsCount;
}
return this.asset.metadata ? 1 : 0;
}
getObjects(): AssetObject[] {
if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) {
return [];
}
if (this._objectsRef === this.asset.objects) {
return this._sortedObjects;
}
this._objectsRef = this.asset.objects;
this._sortedObjects = normalizeAssetObjects(this.asset.objects);
return this._sortedObjects;
}
getObject(ordinal = 1): AssetObject | null {
const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
if (fromObjects) {
return fromObjects;
}
if (ordinal === 1 && this.asset) {
if (this.asset.url || this.asset.metadata) {
return {
ordinal: 1,
url: this.asset.url || null,
metadata: this.asset.metadata || null,
expires_at: this.asset.expiresAt ?? null,
};
}
}
return null;
}
getPrimaryObject(): AssetObject | null {
return this.getObject(1);
}
getPrimaryMetadata(): Record<string, unknown> | null {
return this.getPrimaryObject()?.metadata || null;
}
getPrimaryUrl(): string | null {
return this.getPrimaryObject()?.url || null;
}
hasObject(ordinal: number): boolean {
return Boolean(this.getObject(ordinal));
}
}
export const createAssetView = (asset?: AssetLike | null): AssetView => new AssetView(asset);
export const resolveDocumentAssetUrl = (
doc: DocumentLike | null | undefined,
type: string,
{
ensureAssetUrl,
getAsset,
ensureOptions,
objectOrdinal = 1,
}: {
ensureAssetUrl?: EnsureAssetUrl;
getAsset?: GetAsset;
ensureOptions?: { start?: number; limit?: number; [key: string]: unknown };
objectOrdinal?: number;
} = {},
): string | null => {
if (!doc || !type) {
return null;
}
const asset = typeof getAsset === 'function' ? getAsset(doc, type) : null;
if (!asset) {
return null;
}
const view = createAssetView(asset);
const object = view.getObject(objectOrdinal);
const url = object?.url || (objectOrdinal === 1 ? view.getPrimaryUrl() : null);
const expiresAt = typeof object?.expires_at === 'number'
? object.expires_at
: objectOrdinal === 1 && typeof asset.expiresAt === 'number'
? asset.expiresAt
: null;
const now = Date.now();
if (url && (!expiresAt || expiresAt > now)) {
return url;
}
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
const force = Boolean(url && expiresAt && expiresAt <= now);
const options: { force: boolean; start?: number | null; limit?: number | null; [key: string]: unknown } = {
force,
start: objectOrdinal,
limit: 1,
...(ensureOptions || {}),
};
if (!options.start) {
options.start = objectOrdinal;
}
if (!options.limit) {
options.limit = 1;
}
ensureAssetUrl(doc.id, asset, options).catch(() => {});
}
return null;
};
class AssetManager {
api: AxiosInstance | null;
assetPresignTtlMs: number;
assetCache: Map<Identifier, AssetLike>;
assetInflight: Map<string, Promise<AssetLike | null>>;
constructor({ api, assetPresignTtlMs }: { api: AxiosInstance | null; assetPresignTtlMs: number }) {
this.api = api;
this.assetPresignTtlMs = assetPresignTtlMs;
this.assetCache = new Map();
this.assetInflight = new Map();
}
setApi(api: AxiosInstance | null) {
this.api = api;
}
rememberAsset(entry: AssetLike | null | undefined) {
if (entry?.id) {
this.assetCache.set(entry.id, entry);
}
}
hydrateAsset(asset: AssetLike | null | undefined): AssetLike | null | undefined {
if (!asset || !asset.id) {
return asset;
}
const cached = this.assetCache.get(asset.id);
if (!cached) {
const normalized = mergeAssetObjects(null, asset.objects);
if (normalized.length) {
return { ...asset, objects: normalized };
}
return asset;
}
const merged = { ...cached, ...asset };
if (cached.url && !asset.url) {
merged.url = cached.url;
}
if (cached.expiresAt) {
const cachedExpires = Number(cached.expiresAt) || null;
const assetExpires = Number(asset.expiresAt) || null;
if (!assetExpires || (cachedExpires && cachedExpires > assetExpires)) {
merged.expiresAt = cachedExpires;
}
}
const mergedObjects = mergeAssetObjects(cached.objects, asset.objects);
if (mergedObjects.length) {
merged.objects = mergedObjects;
}
return merged;
}
hydrateDocument(document: DocumentLike | null | undefined): DocumentLike | null | undefined {
if (!document) {
return document;
}
const currentVersion = document.current_version || null;
if (!currentVersion) {
return document;
}
let changed = false;
let nextAssets = currentVersion.assets;
if (nextAssets && !Array.isArray(nextAssets)) {
const hydrated = {};
Object.keys(nextAssets).forEach((key) => {
hydrated[key] = this.hydrateAsset(nextAssets[key]);
if (hydrated[key] !== nextAssets[key]) {
changed = true;
}
});
if (changed) {
nextAssets = { ...nextAssets, ...hydrated };
}
} else if (Array.isArray(nextAssets)) {
const hydratedList = nextAssets.map((item) => this.hydrateAsset(item));
if (
hydratedList.length !== nextAssets.length ||
hydratedList.some((item, index) => item !== nextAssets[index])
) {
changed = true;
nextAssets = hydratedList;
}
}
if (!changed) {
return document;
}
const nextCurrentVersion: DocumentVersionLike = { ...currentVersion, assets: nextAssets };
return { ...document, current_version: nextCurrentVersion };
}
hydrateDocuments(documents: DocumentLike[] | null | undefined) {
if (!Array.isArray(documents)) {
return documents;
}
return documents.map((doc) => this.hydrateDocument(doc));
}
hydrateDetail(detail: { document?: DocumentLike; assets?: AssetLike[] } | null | undefined) {
if (!detail) {
return detail;
}
let changed = false;
const next = { ...detail };
if (detail.document) {
const hydratedDocument = this.hydrateDocument(detail.document);
if (hydratedDocument !== detail.document) {
next.document = hydratedDocument;
changed = true;
}
}
if (Array.isArray(detail.assets)) {
const hydratedAssets = detail.assets.map((item) => this.hydrateAsset(item));
if (
hydratedAssets.length !== detail.assets.length ||
hydratedAssets.some((item, index) => item !== detail.assets[index])
) {
next.assets = hydratedAssets;
changed = true;
}
}
return changed ? next : detail;
}
hydrateFolderContents(contents: {
documents?: DocumentLike[];
document?: DocumentLike;
} | null | undefined) {
if (!contents) {
return contents;
}
const next = { ...contents };
if (Array.isArray(contents.documents)) {
next.documents = this.hydrateDocuments(contents.documents);
}
if (contents.document) {
next.document = this.hydrateDocument(contents.document);
}
return next;
}
ensureAsset(
documentId: Identifier | null | undefined,
asset: AssetLike | null | undefined,
{ force = false, start = null, limit = null }: { force?: boolean; start?: number | null; limit?: number | null } = {},
): Promise<AssetLike | null> {
if (!documentId || !asset?.id) {
return Promise.resolve(asset || null);
}
const requestedStart = Number.isInteger(start) && start > 0 ? start : 1;
const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1;
const requestedEnd = requestedStart + requestedLimit - 1;
const baseAsset = this.assetCache.get(asset.id) || asset;
const view = createAssetView(baseAsset);
const assetExpiresAt = typeof baseAsset.expiresAt === 'number' ? baseAsset.expiresAt : null;
const now = Date.now();
const isOrdinalSatisfied = (ordinal) => {
const object = view.getObject(ordinal);
if (!object) {
return false;
}
if (!object.url) {
return false;
}
if (typeof object.expires_at === 'number') {
return object.expires_at > now;
}
if (ordinal === 1 && baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) {
return true;
}
return true;
};
let needsFetch = force;
if (!needsFetch) {
for (let ordinal = requestedStart; ordinal <= requestedEnd; ordinal += 1) {
if (!isOrdinalSatisfied(ordinal)) {
needsFetch = true;
break;
}
}
}
if (!needsFetch) {
this.rememberAsset(baseAsset);
return Promise.resolve(baseAsset);
}
const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`;
if (!force && this.assetInflight.has(inflightKey)) {
return this.assetInflight.get(inflightKey);
}
if (!this.api) {
return Promise.reject(new Error('AssetManager API client is not configured.'));
}
const params: Record<string, number> = {};
if (Number.isInteger(start) && start > 0) {
params.start = start;
}
if (Number.isInteger(limit) && limit > 0) {
params.limit = limit;
}
const requestConfig = Object.keys(params).length ? { params } : undefined;
const request: Promise<AssetLike | null> = this.api
.get(`/assets/${asset.id}`, requestConfig)
.then(({ data }) => {
const incomingObjects = Array.isArray(data.objects) ? data.objects : [];
const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
const mergedObjects = mergeAssetObjects(cachedEntry?.objects, incomingObjects);
const combined = { ...cachedEntry, ...asset, ...data, objects: mergedObjects };
const view = createAssetView(combined);
const primaryObject = view.getPrimaryObject();
const expiresAt = typeof primaryObject?.expires_at === 'number'
? primaryObject.expires_at
: Date.now() + this.assetPresignTtlMs;
const cardinality = (() => {
const reported = Number(data.cardinality ?? asset.cardinality ?? cachedEntry?.cardinality);
const objectsCount = mergedObjects.length;
if (Number.isFinite(reported) && reported > 0) {
return Math.max(reported, objectsCount) || null;
}
return objectsCount || null;
})();
const entry = {
...combined,
cardinality,
url: view.getPrimaryUrl(),
expiresAt,
};
this.rememberAsset(entry);
return entry;
})
.finally(() => {
this.assetInflight.delete(inflightKey);
});
this.assetInflight.set(inflightKey, request);
return request;
}
reset() {
this.assetCache.clear();
this.assetInflight.clear();
}
}
export default AssetManager;