Files
papercrate/frontend/src/asset_manager.ts
T

276 lines
7.3 KiB
TypeScript

import type { Identifier } from './types/identifiers';
import type { AssetObject, AssetLike } from './types/assets';
import type { DocumentVersion, Document } from './types/documents';
type Nullable<T> = T | null;
export type { AssetObject, AssetLike, DocumentVersion as DocumentVersionLike, Document };
export const resolveAssetExpiresAt = (asset?: { download?: { expires_at: number } | null } | null): number | null =>
asset?.download?.expires_at ?? null;
export const resolveAssetUrl = (asset?: { download?: { url: string } | null } | null): string | null =>
asset?.download?.url ?? null;
export type EnsureAssetUrl = (
documentId: Identifier,
asset: AssetLike,
options?: { force?: boolean;[key: string]: unknown },
) => Promise<unknown>;
export type GetAsset = (document: Document, assetType: string) => Nullable<AssetLike>;
export const getAssetFromGroup = (
assets?: AssetLike[] | Record<string, AssetLike> | null,
assetType: string = '',
): Nullable<AssetLike> => {
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: Nullable<DocumentVersion>, assetType: string) => {
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);
};
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 objectsCount = this.getObjects().length;
if (objectsCount > 0) {
return objectsCount;
}
if (this.asset.url || this.asset.metadata) {
return 1;
}
return 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) {
const primaryUrl = resolveAssetUrl(this.asset);
if (primaryUrl || this.asset.metadata) {
return {
ordinal: 1,
url: primaryUrl || null,
metadata: this.asset.metadata || null,
expires_at: resolveAssetExpiresAt(this.asset),
};
}
}
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: Nullable<Document>,
type: string,
{
ensureAssetUrl,
getAsset,
ensureOptions,
}: {
ensureAssetUrl?: EnsureAssetUrl;
getAsset?: GetAsset;
ensureOptions?: { force?: boolean;[key: string]: unknown };
} = {},
): Nullable<string> => {
if (!doc || !type) {
return null;
}
const asset = getAsset ? getAsset(doc, type) : null;
if (!asset) {
return null;
}
const view = createAssetView(asset);
const object = view.getPrimaryObject();
const url = object?.url || view.getPrimaryUrl();
const expiresAt = object?.expires_at ?? resolveAssetExpiresAt(asset);
const now = Date.now();
if (url && (!expiresAt || expiresAt > now)) {
return url;
}
if (doc.id && asset.id && ensureAssetUrl) {
const force = Boolean(url && expiresAt && expiresAt <= now);
const options: { force: boolean;[key: string]: unknown } = {
force,
...(ensureOptions || {}),
};
ensureAssetUrl(doc.id, asset, options).catch(() => { });
}
return null;
};
class AssetManager {
fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null;
assetCache: Map<Identifier, AssetLike>;
assetInflight: Map<string, Promise<AssetLike | null>>;
constructor({ fetchAsset }: { fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null }) {
this.fetchAsset = fetchAsset;
this.assetCache = new Map();
this.assetInflight = new Map();
}
setFetchAsset(fetchAsset: ((id: Identifier) => Promise<AssetLike | null>) | null) {
this.fetchAsset = fetchAsset;
}
rememberAsset(entry?: Nullable<AssetLike>) {
if (entry?.id) {
this.assetCache.set(entry.id, entry);
}
}
ensureAsset(
documentId?: Identifier | null,
asset?: Nullable<AssetLike>,
{ force = false }: { force?: boolean } = {},
): Promise<Nullable<AssetLike>> {
if (!documentId || !asset?.id) {
return Promise.resolve(asset);
}
const baseAsset = this.assetCache.get(asset.id) || asset;
const view = createAssetView(baseAsset);
const assetExpiresAt = resolveAssetExpiresAt(baseAsset);
const now = Date.now();
const isPrimarySatisfied = () => {
const object = view.getObject(1);
if (object?.url) {
const objectExpiresAt = object.expires_at ?? null;
if (!objectExpiresAt || objectExpiresAt > now) {
return true;
}
}
const assetUrl = resolveAssetUrl(baseAsset);
if (assetUrl && (!assetExpiresAt || assetExpiresAt > now)) {
return true;
}
return false;
};
let needsFetch = force;
if (!needsFetch) {
needsFetch = !isPrimarySatisfied();
}
if (!needsFetch) {
this.rememberAsset(baseAsset);
return Promise.resolve(baseAsset);
}
const inflightKey = `${documentId}:${asset.id}`;
if (!force && this.assetInflight.has(inflightKey)) {
return this.assetInflight.get(inflightKey);
}
if (!this.fetchAsset) {
return Promise.reject(new Error('AssetManager fetcher is not configured.'));
}
const request: Promise<AssetLike | null> = this.fetchAsset(asset.id)
.then((data) => {
if (!data) return null;
const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
const combined = { ...cachedEntry, ...asset, ...data };
const expires_at = resolveAssetExpiresAt(combined);
const entry = {
...combined,
url: resolveAssetUrl(combined),
expires_at,
};
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;