import {BrewUtilShared} from "./utils-brew-helpers.js"; import {BrewDoc} from "./utils-brew-models.js"; export class BrewUtil2Base { _STORAGE_KEY_LEGACY; _STORAGE_KEY_LEGACY_META; // Keep these distinct from the OG brew key, so users can recover their old brew if required. _STORAGE_KEY; _STORAGE_KEY_META; _STORAGE_KEY_CUSTOM_URL; _STORAGE_KEY_MIGRATION_VERSION; _VERSION; _PATH_LOCAL_DIR; _PATH_LOCAL_INDEX; IS_EDITABLE; PAGE_MANAGE; URL_REPO_DEFAULT; URL_REPO_ROOT_DEFAULT; DISPLAY_NAME; DISPLAY_NAME_PLURAL; DEFAULT_AUTHOR; STYLE_BTN; IS_PREFER_DATE_ADDED; _LOCK = new VeLock({name: this.constructor.name}); _cache_iteration = 0; _cache_brewsProc = null; _cache_metas = null; _cache_brews = null; _cache_brewsLocal = null; _isDirty = false; _brewsTemp = []; _addLazy_brewsTemp = []; _storage = StorageUtil; _parent = null; /** * @param {?BrewUtil2Base} parent */ constructor ({parent = null} = {}) { this._parent = parent; } /* -------------------------------------------- */ _pActiveInit = null; pInit () { this._pActiveInit ||= (async () => { // region Ensure the local homebrew cache is hot, to allow us to fetch from it later in a sync manner. // This is necessary to replicate the "meta" caching done for non-local brew. await this._pGetBrew_pGetLocalBrew(); // endregion this._pInit_doBindDragDrop(); this._pInit_pDoLoadFonts().then(null); })(); return this._pActiveInit; } /** @abstract */ _pInit_doBindDragDrop () { throw new Error("Unimplemented!"); } async _pInit_pDoLoadFonts () { const fontFaces = Object.entries( (this._getBrewMetas() || []) .map(({_meta}) => _meta?.fonts || {}) .mergeMap(it => it), ) .map(([family, fontUrl]) => new FontFace(family, `url("${fontUrl}")`)); const results = await Promise.allSettled( fontFaces.map(async fontFace => { await fontFace.load(); return document.fonts.add(fontFace); }), ); const errors = results .filter(({status}) => status === "rejected") .map(({reason}, i) => ({message: `Font "${fontFaces[i].family}" failed to load!`, reason})); if (errors.length) { errors.forEach(({message}) => JqueryUtil.doToast({type: "danger", content: message})); setTimeout(() => { throw new Error(errors.map(({message, reason}) => [message, reason].join("\n")).join("\n\n")); }); } return document.fonts.ready; } /* -------------------------------------------- */ async pGetCustomUrl () { return this._storage.pGet(this._STORAGE_KEY_CUSTOM_URL); } async pSetCustomUrl (val) { await (!val ? this._storage.pRemove(this._STORAGE_KEY_CUSTOM_URL) : this._storage.pSet(this._STORAGE_KEY_CUSTOM_URL, val)); location.reload(); } /* -------------------------------------------- */ isReloadRequired () { return this._isDirty; } doLocationReload ({isRetainHash = false} = {}) { if (!isRetainHash) { if (typeof Hist !== "undefined") Hist.doPreLocationReload(); else window.location.hash = ""; } location.reload(); } _getBrewMetas () { return [ ...(this._storage.syncGet(this._STORAGE_KEY_META) || []), ...(this._cache_brewsLocal || []).map(brew => this._getBrewDocReduced(brew)), ]; } _setBrewMetas (val) { this._cache_metas = null; return this._storage.syncSet(this._STORAGE_KEY_META, val); } /** Fetch the brew as though it has been loaded from site URL. */ async pGetBrewProcessed () { if (this._cache_brewsProc) return this._cache_brewsProc; // Short-circuit if the cache is already available try { const lockToken = await this._LOCK.pLock(); await this._pGetBrewProcessed_({lockToken}); } catch (e) { setTimeout(() => { throw e; }); } finally { this._LOCK.unlock(); } return this._cache_brewsProc; } async _pGetBrewProcessed_ ({lockToken}) { const cpyBrews = MiscUtil.copyFast([ ...await this.pGetBrew({lockToken}), ...this._brewsTemp, ]); if (!cpyBrews.length) return this._cache_brewsProc = {}; await this._pGetBrewProcessed_pDoBlocklistExtension({cpyBrews}); // Avoid caching the meta merge, as we have our own cache. We might edit the brew, so we don't want a stale copy. const cpyBrewsLoaded = await cpyBrews.pSerialAwaitMap(async ({head, body}) => { const cpyBrew = await DataUtil.pDoMetaMerge(head.url || head.docIdLocal, body, {isSkipMetaMergeCache: true}); this._pGetBrewProcessed_mutDiagnostics({head, cpyBrew}); return cpyBrew; }); this._cache_brewsProc = this._pGetBrewProcessed_getMergedOutput({cpyBrewsLoaded}); return this._cache_brewsProc; } /** Homebrew files can contain embedded blocklists. */ async _pGetBrewProcessed_pDoBlocklistExtension ({cpyBrews}) { for (const {body} of cpyBrews) { if (!body?.blocklist?.length || !(body.blocklist instanceof Array)) continue; await ExcludeUtil.pExtendList(body.blocklist); } } _pGetBrewProcessed_mutDiagnostics ({head, cpyBrew}) { if (!head.filename) return; for (const arr of Object.values(cpyBrew)) { if (!(arr instanceof Array)) continue; for (const ent of arr) { if (!("__prop" in ent)) break; ent.__diagnostic = {filename: head.filename}; } } } _pGetBrewProcessed_getMergedOutput ({cpyBrewsLoaded}) { return BrewDoc.mergeObjects(undefined, ...cpyBrewsLoaded); } /** * TODO refactor such that this is not necessary * @deprecated */ getBrewProcessedFromCache (prop) { return this._cache_brewsProc?.[prop] || []; } /* -------------------------------------------- */ /** Fetch the raw brew from storage. */ async pGetBrew ({lockToken} = {}) { if (this._cache_brews) return this._cache_brews; try { lockToken = await this._LOCK.pLock({token: lockToken}); const out = [ ...(await this._pGetBrewRaw({lockToken})), ...(await this._pGetBrew_pGetLocalBrew({lockToken})), ]; return this._cache_brews = out // Ensure no brews which lack sources are loaded .filter(brew => brew?.body?._meta?.sources?.length); } finally { this._LOCK.unlock(); } } /* -------------------------------------------- */ async pGetBrewBySource (source, {lockToken} = {}) { const brews = await this.pGetBrew({lockToken}); return brews.find(brew => brew?.body?._meta?.sources?.some(src => src?.json === source)); } /* -------------------------------------------- */ async _pGetBrew_pGetLocalBrew ({lockToken} = {}) { if (this._cache_brewsLocal) return this._cache_brewsLocal; if (IS_VTT || IS_DEPLOYED || typeof window === "undefined") return this._cache_brewsLocal = []; try { await this._LOCK.pLock({token: lockToken}); return (await this._pGetBrew_pGetLocalBrew_()); } finally { this._LOCK.unlock(); } } async _pGetBrew_pGetLocalBrew_ () { // auto-load from `prerelease/` and `homebrew/`, for custom versions of the site const indexLocal = await DataUtil.loadJSON(`${Renderer.get().baseUrl}${this._PATH_LOCAL_INDEX}`); if (!indexLocal?.toImport?.length) return this._cache_brewsLocal = []; const brewDocs = (await indexLocal.toImport .pMap(async name => { name = `${name}`.trim(); const url = /^https?:\/\//.test(name) ? name : `${Renderer.get().baseUrl}${this._PATH_LOCAL_DIR}/${name}`; const filename = UrlUtil.getFilename(url); try { const json = await DataUtil.loadRawJSON(url); return this._getBrewDoc({json, url, filename, isLocal: true}); } catch (e) { JqueryUtil.doToast({type: "danger", content: `Failed to load local homebrew from URL "${url}"! ${VeCt.STR_SEE_CONSOLE}`}); setTimeout(() => { throw e; }); return null; } })) .filter(Boolean); return this._cache_brewsLocal = brewDocs; } /* -------------------------------------------- */ async _pGetBrewRaw ({lockToken} = {}) { try { await this._LOCK.pLock({token: lockToken}); return (await this._pGetBrewRaw_()); } finally { this._LOCK.unlock(); } } async _pGetBrewRaw_ () { const brewRaw = (await this._storage.pGet(this._STORAGE_KEY)) || []; // Assume that any potential migration has been completed if the user has new homebrew if (brewRaw.length) return brewRaw; const {version, existingMeta, existingBrew} = await this._pGetMigrationInfo(); if (version === this._VERSION) return brewRaw; if (!existingMeta || !existingBrew) { await this._storage.pSet(this._STORAGE_KEY_MIGRATION_VERSION, this._VERSION); return brewRaw; } // If the user has no new homebrew, and some old homebrew, migrate the old homebrew. // Move the existing brew to the editable document--we do this as there is no guarantee that the user has not e.g. // edited the brew they had saved. const brewEditable = this._getNewEditableBrewDoc(); const cpyBrewEditableDoc = BrewDoc.fromObject(brewEditable, {isCopy: true}) .mutMerge({ json: { _meta: existingMeta || {}, ...existingBrew, }, }); await this._pSetBrew_({val: [cpyBrewEditableDoc], isInitialMigration: true}); // Update the version, but do not delete the legacy brew--if the user really wants to get rid of it, they can // clear their storage/etc. await this._storage.pSet(this._STORAGE_KEY_MIGRATION_VERSION, this._VERSION); JqueryUtil.doToast(`Migrated ${this.DISPLAY_NAME} from version ${version} to version ${this._VERSION}!`); return this._storage.pGet(this._STORAGE_KEY); } _getNewEditableBrewDoc () { const json = {_meta: {sources: []}}; return this._getBrewDoc({json, isEditable: true}); } /* -------------------------------------------- */ async _pGetMigrationInfo () { // If there is no migration support, return default info if (!this._STORAGE_KEY_LEGACY && !this._STORAGE_KEY_LEGACY_META) return {version: this._VERSION, existingBrew: null, existingMeta: null}; const version = await this._storage.pGet(this._STORAGE_KEY_MIGRATION_VERSION); // Short-circuit if we know we're already on the right version, to avoid loading old data if (version === this._VERSION) return {version}; const existingBrew = await this._storage.pGet(this._STORAGE_KEY_LEGACY); const existingMeta = await this._storage.syncGet(this._STORAGE_KEY_LEGACY_META); return { version: version ?? 1, existingBrew, existingMeta, }; } /* -------------------------------------------- */ getCacheIteration () { return this._cache_iteration; } /* -------------------------------------------- */ async pSetBrew (val, {lockToken} = {}) { try { await this._LOCK.pLock({token: lockToken}); await this._pSetBrew_({val}); } finally { this._LOCK.unlock(); } } async _pSetBrew_ ({val, isInitialMigration}) { this._mutBrewsForSet(val); if (!isInitialMigration) { if (this._cache_brewsProc) this._cache_iteration++; this._cache_brews = null; this._cache_brewsProc = null; } await this._storage.pSet(this._STORAGE_KEY, val); if (!isInitialMigration) this._isDirty = true; } _mutBrewsForSet (val) { if (!(val instanceof Array)) throw new Error(`${this.DISPLAY_NAME.uppercaseFirst()} array must be an array!`); this._setBrewMetas(val.map(brew => this._getBrewDocReduced(brew))); } /* -------------------------------------------- */ _getBrewId (brew) { if (brew.head.url) return brew.head.url; if (brew.body._meta?.sources?.length) return brew.body._meta.sources.map(src => (src.json || "").toLowerCase()).sort(SortUtil.ascSortLower).join(" :: "); return null; } _getNextBrews (brews, brewsToAdd) { const idsToAdd = new Set(brewsToAdd.map(brews => this._getBrewId(brews)).filter(Boolean)); brews = brews.filter(brew => { const id = this._getBrewId(brew); if (id == null) return true; return !idsToAdd.has(id); }); return [...brews, ...brewsToAdd]; } /* -------------------------------------------- */ async _pLoadParentDependencies ({unavailableSources}) { if (!unavailableSources?.length) return false; if (!this._parent) return false; await Promise.allSettled(unavailableSources.map(async source => { const url = await this._parent.pGetSourceUrl(source); if (!url) return; await this._parent.pAddBrewFromUrl(url, {isLazy: true}); })); await this._parent.pAddBrewsLazyFinalize(); return false; } /* -------------------------------------------- */ async _pGetBrewDependencies ({brewDocs, brewsRaw = null, brewsRawLocal = null, isIgnoreNetworkErrors = false, lockToken}) { try { lockToken = await this._LOCK.pLock({token: lockToken}); return (await this._pGetBrewDependencies_({brewDocs, brewsRaw, brewsRawLocal, isIgnoreNetworkErrors, lockToken})); } finally { this._LOCK.unlock(); } } async _pGetBrewDependencies_ ({brewDocs, brewsRaw = null, brewsRawLocal = null, isIgnoreNetworkErrors = false, lockToken}) { const urlRoot = await this.pGetCustomUrl(); const brewIndex = await this._pGetBrewDependencies_getBrewIndex({urlRoot, isIgnoreNetworkErrors}); const toLoadSources = []; const loadedSources = new Set(); const unavailableSources = new Set(); const brewDocsDependencies = []; brewsRaw = brewsRaw || await this._pGetBrewRaw({lockToken}); brewsRawLocal = brewsRawLocal || await this._pGetBrew_pGetLocalBrew({lockToken}); brewDocs.forEach(brewDoc => this._pGetBrewDependencies_mutAddLoaded({loadedSources, brewDoc})); brewsRaw.forEach(brewDoc => this._pGetBrewDependencies_mutAddLoaded({loadedSources, brewDoc})); brewsRawLocal.forEach(brewDoc => this._pGetBrewDependencies_mutAddLoaded({loadedSources, brewDoc})); brewDocs .forEach(brewDoc => { const {available, unavailable} = this._getBrewDependencySources({brewDoc, brewIndex}); available.forEach(src => this._pGetBrewDependencies_mutAddToLoad({loadedSources, toLoadSources, src})); unavailable.forEach(src => unavailableSources.add(src)); }); while (toLoadSources.length) { const src = toLoadSources.pop(); if (loadedSources.has(src)) continue; loadedSources.add(src); const url = this.getFileUrl(brewIndex[src], urlRoot); const brewDocDep = await this._pGetBrewDocFromUrl({url}); brewDocsDependencies.push(brewDocDep); this._pGetBrewDependencies_mutAddLoaded({loadedSources, brewDoc: brewDocDep}); const {available, unavailable} = this._getBrewDependencySources({brewDoc: brewDocDep, brewIndex}); available.forEach(src => this._pGetBrewDependencies_mutAddToLoad({loadedSources, toLoadSources, src})); unavailable.forEach(src => unavailableSources.add(src)); } return { brewDocsDependencies, unavailableSources: [...unavailableSources].sort(SortUtil.ascSortLower), }; } _pGetBrewDependencies_mutAddLoaded ({loadedSources, brewDoc}) { (brewDoc.body._meta?.sources || []) .filter(src => src.json) .forEach(src => loadedSources.add(src.json)); } _pGetBrewDependencies_mutAddToLoad ({loadedSources, toLoadSources, src}) { if (loadedSources.has(src) || toLoadSources.includes(src)) return; toLoadSources.push(src); } async _pGetBrewDependencies_getBrewIndex ({urlRoot, isIgnoreNetworkErrors = false}) { try { return (await this.pGetSourceIndex(urlRoot)); } catch (e) { // Support limited use for e.g. offline file uploads if (isIgnoreNetworkErrors) return {}; throw e; } } async pGetSourceUrl (source) { const urlRoot = await this.pGetCustomUrl(); const brewIndex = await this.pGetSourceIndex(urlRoot); if (brewIndex[source]) return this.getFileUrl(brewIndex[source], urlRoot); const sourceLower = source.toLowerCase(); if (brewIndex[sourceLower]) return this.getFileUrl(brewIndex[sourceLower], urlRoot); const sourceOriginal = Object.keys(brewIndex).find(k => k.toLowerCase() === sourceLower); if (!brewIndex[sourceOriginal]) return null; return this.getFileUrl(brewIndex[sourceOriginal], urlRoot); } /* -------------------------------------------- */ /** @abstract */ async pGetSourceIndex (urlRoot) { throw new Error("Unimplemented!"); } /** @abstract */ getFileUrl (path, urlRoot) { throw new Error("Unimplemented!"); } /** @abstract */ pLoadTimestamps (urlRoot) { throw new Error("Unimplemented!"); } /** @abstract */ pLoadPropIndex (urlRoot) { throw new Error("Unimplemented!"); } /** @abstract */ pLoadMetaIndex (urlRoot) { throw new Error("Unimplemented!"); } /** @abstract */ pLoadAdventureBookIdsIndex (urlRoot) { throw new Error("Unimplemented!"); } async pGetCombinedIndexes () { const urlRoot = await this.pGetCustomUrl(); const indexes = await this._pGetCombinedIndexes_pGetIndexes({urlRoot}); // Tolerate e.g. opening when offline if (!indexes) return null; const {timestamps, propIndex, metaIndex, sourceIndex} = indexes; const pathToMeta = {}; Object.entries(propIndex) .forEach(([prop, pathToDir]) => { Object.entries(pathToDir) .forEach(([path, dir]) => { pathToMeta[path] = pathToMeta[path] || {dir, props: []}; pathToMeta[path].props.push(prop); }); }); Object.entries(sourceIndex) .forEach(([src, path]) => { if (!pathToMeta[path]) return; (pathToMeta[path].sources ||= []).push(src); }); return Object.entries(pathToMeta) .map(([path, meta]) => { const out = { urlDownload: this.getFileUrl(path, urlRoot), path, name: UrlUtil.getFilename(path), dirProp: this.getDirProp(meta.dir), props: meta.props, sources: meta.sources, }; const spl = out.name.trim().replace(/\.json$/, "").split(";").map(it => it.trim()); if (spl.length > 1) { out._brewName = spl[1]; out._brewAuthor = spl[0]; } else { out._brewName = spl[0]; out._brewAuthor = this.DEFAULT_AUTHOR; } out._brewAdded = timestamps[out.path]?.a ?? 0; out._brewModified = timestamps[out.path]?.m ?? 0; out._brewPublished = timestamps[out.path]?.p ?? 0; out._brewInternalSources = metaIndex[out.name]?.n || []; out._brewStatus = metaIndex[out.name]?.s || "ready"; out._brewIsPartnered = !!metaIndex[out.name]?.p; out._brewPropDisplayName = this.getPropDisplayName(out.dirProp); return out; }) .sort((a, b) => SortUtil.ascSortLower(a._brewName, b._brewName)); } async _pGetCombinedIndexes_pGetIndexes ({urlRoot}) { try { const [timestamps, propIndex, metaIndex, sourceIndex] = await Promise.all([ this.pLoadTimestamps(urlRoot), this.pLoadPropIndex(urlRoot), this.pLoadMetaIndex(urlRoot), this.pGetSourceIndex(urlRoot), ]); return { timestamps, propIndex, metaIndex, sourceIndex, }; } catch (e) { JqueryUtil.doToast({content: `Failed to load ${this.DISPLAY_NAME} indexes! ${VeCt.STR_SEE_CONSOLE}`, type: "danger"}); setTimeout(() => { throw e; }); return null; } } /* -------------------------------------------- */ _PROPS_DEPS = ["dependencies", "includes"]; _PROPS_DEPS_DEEP = ["otherSources"]; _getBrewDependencySources ({brewDoc, brewIndex}) { const sources = new Set(); this._PROPS_DEPS.forEach(prop => { const obj = brewDoc.body._meta?.[prop]; if (!obj || !Object.keys(obj).length) return; Object.values(obj) .flat() .forEach(src => sources.add(src)); }); this._PROPS_DEPS_DEEP.forEach(prop => { const obj = brewDoc.body._meta?.[prop]; if (!obj || !Object.keys(obj).length) return; return Object.values(obj) .map(objSub => Object.keys(objSub)) .flat() .forEach(src => sources.add(src)); }); const [available, unavailable] = [...sources] .segregate(src => brewIndex[src]); return {available, unavailable}; } async pAddBrewFromUrl (url, {isLazy} = {}) { let brewDocs = []; let unavailableSources = []; try { ({brewDocs, unavailableSources} = await this._pAddBrewFromUrl({url, isLazy})); } catch (e) { JqueryUtil.doToast({type: "danger", content: `Failed to load ${this.DISPLAY_NAME} from URL "${url}"! ${VeCt.STR_SEE_CONSOLE}`}); setTimeout(() => { throw e; }); return []; } await this._pLoadParentDependencies({unavailableSources}); return brewDocs; } async _pGetBrewDocFromUrl ({url}) { const json = await DataUtil.loadRawJSON(url); return this._getBrewDoc({json, url, filename: UrlUtil.getFilename(url)}); } async _pAddBrewFromUrl ({url, lockToken, isLazy}) { const brewDoc = await this._pGetBrewDocFromUrl({url}); if (isLazy) { try { await this._LOCK.pLock({token: lockToken}); this._addLazy_brewsTemp.push(brewDoc); } finally { this._LOCK.unlock(); } return {brewDocs: [brewDoc], unavailableSources: []}; } const brewDocs = [brewDoc]; const unavailableSources = []; try { lockToken = await this._LOCK.pLock({token: lockToken}); const brews = MiscUtil.copyFast(await this._pGetBrewRaw({lockToken})); const {brewDocsDependencies, unavailableSources: unavailableSources_} = await this._pGetBrewDependencies({brewDocs, brewsRaw: brews, lockToken}); brewDocs.push(...brewDocsDependencies); unavailableSources.push(...unavailableSources_); const brewsNxt = this._getNextBrews(brews, brewDocs); await this.pSetBrew(brewsNxt, {lockToken}); } finally { this._LOCK.unlock(); } return {brewDocs, unavailableSources}; } async pAddBrewsFromFiles (files) { let brewDocs = []; let unavailableSources = []; try { const lockToken = await this._LOCK.pLock(); ({brewDocs, unavailableSources} = await this._pAddBrewsFromFiles({files, lockToken})); } catch (e) { JqueryUtil.doToast({type: "danger", content: `Failed to load ${this.DISPLAY_NAME} from file(s)! ${VeCt.STR_SEE_CONSOLE}`}); setTimeout(() => { throw e; }); return []; } finally { this._LOCK.unlock(); } await this._pLoadParentDependencies({unavailableSources}); return brewDocs; } async _pAddBrewsFromFiles ({files, lockToken}) { const brewDocs = files.map(file => this._getBrewDoc({json: file.json, filename: file.name})); const brews = MiscUtil.copyFast(await this._pGetBrewRaw({lockToken})); const {brewDocsDependencies, unavailableSources} = await this._pGetBrewDependencies({brewDocs, brewsRaw: brews, isIgnoreNetworkErrors: true, lockToken}); brewDocs.push(...brewDocsDependencies); const brewsNxt = this._getNextBrews(brews, brewDocs); await this.pSetBrew(brewsNxt, {lockToken}); return {brewDocs, unavailableSources}; } async pAddBrewsLazyFinalize () { let brewDocs = []; let unavailableSources = []; try { const lockToken = await this._LOCK.pLock(); ({brewDocs, unavailableSources} = await this._pAddBrewsLazyFinalize_({lockToken})); } catch (e) { JqueryUtil.doToast({type: "danger", content: `Failed to finalize ${this.DISPLAY_NAME_PLURAL}! ${VeCt.STR_SEE_CONSOLE}`}); setTimeout(() => { throw e; }); return []; } finally { this._LOCK.unlock(); } await this._pLoadParentDependencies({unavailableSources}); return brewDocs; } async _pAddBrewsLazyFinalize_ ({lockToken}) { const brewsRaw = await this._pGetBrewRaw({lockToken}); const {brewDocsDependencies, unavailableSources} = await this._pGetBrewDependencies({brewDocs: this._addLazy_brewsTemp, brewsRaw, lockToken}); const brewDocs = MiscUtil.copyFast(brewDocsDependencies); const brewsNxt = this._getNextBrews(MiscUtil.copyFast(brewsRaw), [...this._addLazy_brewsTemp, ...brewDocsDependencies]); await this.pSetBrew(brewsNxt, {lockToken}); this._addLazy_brewsTemp = []; return {brewDocs, unavailableSources}; } async pPullAllBrews ({brews} = {}) { try { const lockToken = await this._LOCK.pLock(); return (await this._pPullAllBrews_({lockToken, brews})); } finally { this._LOCK.unlock(); } } async _pPullAllBrews_ ({lockToken, brews}) { let cntPulls = 0; brews = brews || MiscUtil.copyFast(await this._pGetBrewRaw({lockToken})); const brewsNxt = await brews.pMap(async brew => { if (!this.isPullable(brew)) return brew; const json = await DataUtil.loadRawJSON(brew.head.url, {isBustCache: true}); const localLastModified = brew.body._meta?.dateLastModified ?? 0; const sourceLastModified = json._meta?.dateLastModified ?? 0; if (sourceLastModified <= localLastModified) return brew; cntPulls++; return BrewDoc.fromObject(brew).mutUpdate({json}).toObject(); }); if (!cntPulls) return cntPulls; await this.pSetBrew(brewsNxt, {lockToken}); return cntPulls; } isPullable (brew) { return !brew.head.isEditable && !!brew.head.url; } async pPullBrew (brew) { try { const lockToken = await this._LOCK.pLock(); return (await this._pPullBrew_({brew, lockToken})); } finally { this._LOCK.unlock(); } } async _pPullBrew_ ({brew, lockToken}) { const brews = await this._pGetBrewRaw({lockToken}); if (!brews?.length) return; let isPull = false; const brewsNxt = await brews.pMap(async it => { if (it.head.docIdLocal !== brew.head.docIdLocal || !this.isPullable(it)) return it; const json = await DataUtil.loadRawJSON(it.head.url, {isBustCache: true}); const localLastModified = it.body._meta?.dateLastModified ?? 0; const sourceLastModified = json._meta?.dateLastModified ?? 0; if (sourceLastModified <= localLastModified) return it; isPull = true; return BrewDoc.fromObject(it).mutUpdate({json}).toObject(); }); if (!isPull) return isPull; await this.pSetBrew(brewsNxt, {lockToken}); return isPull; } async pAddBrewFromLoaderTag (ele) { const $ele = $(ele); if (!$ele.hasClass("rd__wrp-loadbrew--ready")) return; // an existing click is being handled let jsonPath = ele.dataset.rdLoaderPath; const name = ele.dataset.rdLoaderName; const cached = $ele.html(); const cachedTitle = $ele.title(); $ele.title(""); $ele.removeClass("rd__wrp-loadbrew--ready").html(`${name.qq()}`); jsonPath = jsonPath.unescapeQuotes(); if (!UrlUtil.isFullUrl(jsonPath)) { const brewUrl = await this.pGetCustomUrl(); jsonPath = this.getFileUrl(jsonPath, brewUrl); } await this.pAddBrewFromUrl(jsonPath); $ele.html(`${name.qq()}`); setTimeout(() => $ele.html(cached).addClass("rd__wrp-loadbrew--ready").title(cachedTitle), 500); } async pAddBrewsPartnered ({isSilent = false} = {}) { const combinedIndexes = await this.pGetCombinedIndexes(); const brewInfos = combinedIndexes.filter(it => it._brewIsPartnered); if (!brewInfos.length) { if (!isSilent) JqueryUtil.doToast({type: "warning", content: `Did not find any partnered ${this.DISPLAY_NAME} to load!`}); return []; } if (!isSilent) JqueryUtil.doToast(`Found ${brewInfos.length} partnered ${brewInfos.length === 1 ? this.DISPLAY_NAME : this.DISPLAY_NAME_PLURAL}; loading...`); await brewInfos .pMap(brewInfo => this.pAddBrewFromUrl(brewInfo.urlDownload, {isLazy: true})); const brewDocsAdded = await this.pAddBrewsLazyFinalize(); if (!isSilent) { const numAdded = brewInfos.length + brewDocsAdded.length; if (numAdded) JqueryUtil.doToast(`Loaded ${numAdded} partnered ${numAdded === 1 ? this.DISPLAY_NAME : this.DISPLAY_NAME_PLURAL}!`); else JqueryUtil.doToast({type: "warning", content: `Did not load any partnered ${this.DISPLAY_NAME}!`}); } return brewDocsAdded; } _getBrewDoc ({json, url = null, filename = null, isLocal = false, isEditable = false}) { return BrewDoc.fromValues({ head: { json, url, filename, isLocal, isEditable, }, body: json, }).toObject(); } _getBrewDocReduced (brewDoc) { return {docIdLocal: brewDoc.head.docIdLocal, _meta: brewDoc.body._meta}; } async pDeleteBrews (brews) { try { const lockToken = await this._LOCK.pLock(); await this._pDeleteBrews_({brews, lockToken}); } finally { this._LOCK.unlock(); } } async _pDeleteBrews_ ({brews, lockToken}) { const brewsStored = await this._pGetBrewRaw({lockToken}); if (!brewsStored?.length) return; const idsToDelete = new Set(brews.map(brew => brew.head.docIdLocal)); const nxtBrews = brewsStored.filter(brew => !idsToDelete.has(brew.head.docIdLocal)); await this.pSetBrew(nxtBrews, {lockToken}); } async pDeleteUneditableBrews () { try { const lockToken = await this._LOCK.pLock(); await this._pDeleteUneditableBrews_({lockToken}); } finally { this._LOCK.unlock(); } } async _pDeleteUneditableBrews_ ({lockToken}) { const brewsStored = await this._pGetBrewRaw({lockToken}); if (!brewsStored?.length) return; const nxtBrews = brewsStored.filter(brew => brew.head.isEditable); await this.pSetBrew(nxtBrews, {lockToken}); } async pUpdateBrew (brew) { try { const lockToken = await this._LOCK.pLock(); await this._pUpdateBrew_({brew, lockToken}); } finally { this._LOCK.unlock(); } } async _pUpdateBrew_ ({brew, lockToken}) { const brews = await this._pGetBrewRaw({lockToken}); if (!brews?.length) return; const nxtBrews = brews.map(it => it.head.docIdLocal !== brew.head.docIdLocal ? it : brew); await this.pSetBrew(nxtBrews, {lockToken}); } // region Editable /** @abstract */ pGetEditableBrewDoc (brew) { throw new Error("Unimplemented"); } /** @abstract */ pGetOrCreateEditableBrewDoc () { throw new Error("Unimplemented"); } /** @abstract */ pSetEditableBrewDoc () { throw new Error("Unimplemented"); } /** @abstract */ pGetEditableBrewEntity (prop, uniqueId, {isDuplicate = false} = {}) { throw new Error("Unimplemented"); } /** @abstract */ pPersistEditableBrewEntity (prop, ent) { throw new Error("Unimplemented"); } /** @abstract */ pRemoveEditableBrewEntity (prop, uniqueId) { throw new Error("Unimplemented"); } /** @abstract */ pAddSource (sourceObj) { throw new Error("Unimplemented"); } /** @abstract */ pEditSource (sourceObj) { throw new Error("Unimplemented"); } /** @abstract */ pIsEditableSourceJson (sourceJson) { throw new Error("Unimplemented"); } /** @abstract */ pMoveOrCopyToEditableBySourceJson (sourceJson) { throw new Error("Unimplemented"); } /** @abstract */ pMoveToEditable ({brews}) { throw new Error("Unimplemented"); } /** @abstract */ pCopyToEditable ({brews}) { throw new Error("Unimplemented"); } /** @abstract */ pHasEditableSourceJson () { throw new Error("Unimplemented"); } // endregion // region Rendering/etc. _PAGE_TO_PROPS__SPELLS = [...UrlUtil.PAGE_TO_PROPS[UrlUtil.PG_SPELLS], "spellFluff"]; _PAGE_TO_PROPS__BESTIARY = ["monster", "legendaryGroup", "monsterFluff"]; _PAGE_TO_PROPS = { [UrlUtil.PG_SPELLS]: this._PAGE_TO_PROPS__SPELLS, [UrlUtil.PG_CLASSES]: ["class", "subclass", "classFeature", "subclassFeature"], [UrlUtil.PG_BESTIARY]: this._PAGE_TO_PROPS__BESTIARY, [UrlUtil.PG_BACKGROUNDS]: ["background"], [UrlUtil.PG_FEATS]: ["feat"], [UrlUtil.PG_OPT_FEATURES]: ["optionalfeature"], [UrlUtil.PG_RACES]: [...UrlUtil.PAGE_TO_PROPS[UrlUtil.PG_RACES], "raceFluff"], [UrlUtil.PG_OBJECTS]: ["object"], [UrlUtil.PG_TRAPS_HAZARDS]: ["trap", "hazard"], [UrlUtil.PG_DEITIES]: ["deity"], [UrlUtil.PG_ITEMS]: [...UrlUtil.PAGE_TO_PROPS[UrlUtil.PG_ITEMS], "itemFluff"], [UrlUtil.PG_REWARDS]: ["reward"], [UrlUtil.PG_PSIONICS]: ["psionic"], [UrlUtil.PG_VARIANTRULES]: ["variantrule"], [UrlUtil.PG_CONDITIONS_DISEASES]: ["condition", "disease", "status"], [UrlUtil.PG_ADVENTURES]: ["adventure", "adventureData"], [UrlUtil.PG_BOOKS]: ["book", "bookData"], [UrlUtil.PG_TABLES]: ["table", "tableGroup"], [UrlUtil.PG_MAKE_BREW]: [ ...this._PAGE_TO_PROPS__SPELLS, ...this._PAGE_TO_PROPS__BESTIARY, "makebrewCreatureTrait", ], [UrlUtil.PG_MANAGE_BREW]: ["*"], [UrlUtil.PG_MANAGE_PRERELEASE]: ["*"], [UrlUtil.PG_DEMO_RENDER]: ["*"], [UrlUtil.PG_VEHICLES]: ["vehicle", "vehicleUpgrade"], [UrlUtil.PG_ACTIONS]: ["action"], [UrlUtil.PG_CULTS_BOONS]: ["cult", "boon"], [UrlUtil.PG_LANGUAGES]: ["language", "languageScript"], [UrlUtil.PG_CHAR_CREATION_OPTIONS]: ["charoption"], [UrlUtil.PG_RECIPES]: ["recipe"], [UrlUtil.PG_CLASS_SUBCLASS_FEATURES]: ["classFeature", "subclassFeature"], [UrlUtil.PG_DECKS]: ["card", "deck"], }; getPageProps ({page, isStrict = false, fallback = null} = {}) { page = this._getBrewPage(page); const out = this._PAGE_TO_PROPS[page]; if (out) return out; if (fallback) return fallback; if (isStrict) throw new Error(`No ${this.DISPLAY_NAME} properties defined for category ${page}`); return null; } getPropPages () { return Object.entries(this._PAGE_TO_PROPS) .map(([page, props]) => [page, props.filter(it => it !== "*")]) .filter(([, props]) => props.length) .map(([page]) => page); } _getBrewPage (page) { return page || (IS_VTT ? this.PAGE_MANAGE : UrlUtil.getCurrentPage()); } getDirProp (dir) { switch (dir) { case "creature": return "monster"; case "makebrew": return "makebrewCreatureTrait"; } return dir; } getPropDisplayName (prop) { switch (prop) { case "adventure": return "Adventure Contents/Info"; case "book": return "Book Contents/Info"; } return Parser.getPropDisplayName(prop); } // endregion // region Sources _doCacheMetas () { if (this._cache_metas) return; this._cache_metas = {}; (this._getBrewMetas() || []) .forEach(({_meta}) => { Object.entries(_meta || {}) .forEach(([prop, val]) => { if (!val) return; if (typeof val !== "object") return; if (val instanceof Array) { (this._cache_metas[prop] = this._cache_metas[prop] || []).push(...MiscUtil.copyFast(val)); return; } this._cache_metas[prop] = this._cache_metas[prop] || {}; Object.assign(this._cache_metas[prop], MiscUtil.copyFast(val)); }); }); // Add a special "_sources" cache, which is a lookup-friendly object (rather than "sources", which is an array) this._cache_metas["_sources"] = (this._getBrewMetas() || []) .mergeMap(({_meta}) => { return (_meta?.sources || []) .mergeMap(src => ({[(src.json || "").toLowerCase()]: MiscUtil.copyFast(src)})); }); } hasSourceJson (source) { if (!source) return false; source = source.toLowerCase(); return !!this.getMetaLookup("_sources")[source]; } sourceJsonToFull (source) { if (!source) return ""; source = source.toLowerCase(); return this.getMetaLookup("_sources")[source]?.full || source; } sourceJsonToAbv (source) { if (!source) return ""; source = source.toLowerCase(); return this.getMetaLookup("_sources")[source]?.abbreviation || source; } sourceJsonToDate (source) { if (!source) return ""; source = source.toLowerCase(); return this.getMetaLookup("_sources")[source]?.dateReleased || "1970-01-01"; } sourceJsonToSource (source) { if (!source) return null; source = source.toLowerCase(); return this.getMetaLookup("_sources")[source]; } sourceJsonToStyle (source) { const stylePart = this.sourceJsonToStylePart(source); if (!stylePart) return stylePart; return `style="${stylePart}"`; } sourceToStyle (source) { const stylePart = this.sourceToStylePart(source); if (!stylePart) return stylePart; return `style="${stylePart}"`; } sourceJsonToStylePart (source) { if (!source) return ""; const color = this.sourceJsonToColor(source); if (color) return MiscUtil.getColorStylePart(color); return ""; } sourceToStylePart (source) { if (!source) return ""; const color = this.sourceToColor(source); if (color) return MiscUtil.getColorStylePart(color); return ""; } sourceJsonToColor (source) { if (!source) return ""; source = source.toLowerCase(); if (!this.getMetaLookup("_sources")[source]?.color) return ""; return BrewUtilShared.getValidColor(this.getMetaLookup("_sources")[source].color); } sourceToColor (source) { if (!source?.color) return ""; return BrewUtilShared.getValidColor(source.color); } getSources () { this._doCacheMetas(); return Object.values(this._cache_metas["_sources"]); } // endregion // region Other meta getMetaLookup (type) { if (!type) return null; this._doCacheMetas(); return this._cache_metas[type]; } // endregion /** * Merge together a loaded JSON (or loaded-JSON-like) object and a processed homebrew object. * @param data * @param homebrew */ getMergedData (data, homebrew) { const out = {}; Object.entries(data) .forEach(([prop, val]) => { if (!homebrew[prop]) { out[prop] = [...val]; return; } if (!(homebrew[prop] instanceof Array)) throw new Error(`${this.DISPLAY_NAME.uppercaseFirst()} was not array!`); if (!(val instanceof Array)) throw new Error(`Data was not array!`); out[prop] = [...val, ...homebrew[prop]]; }); return out; } // region Search /** * Get data in a format similar to the main search index */ async pGetSearchIndex ({id = 0, isDecompress = true, isIncludeExtendedSourceInfo = false} = {}) { const indexer = new Omnidexer(id); const brew = await this.pGetBrewProcessed(); // Run these in serial, to prevent any ID race condition antics await [...Omnidexer.TO_INDEX__FROM_INDEX_JSON, ...Omnidexer.TO_INDEX] .pSerialAwaitMap(async arbiter => { if (arbiter.isSkipBrew) return; if (!brew[arbiter.brewProp || arbiter.listProp]?.length) return; if (arbiter.pFnPreProcBrew) { const toProc = await arbiter.pFnPreProcBrew.bind(arbiter)(brew); await indexer.pAddToIndex(arbiter, toProc, {isIncludeExtendedSourceInfo}); return; } await indexer.pAddToIndex(arbiter, brew, {isIncludeExtendedSourceInfo}); }); const index = indexer.getIndex(); if (!isDecompress) return index; return Omnidexer.decompressIndex(index); } async pGetAdditionalSearchIndices (highestId, addiProp) { const indexer = new Omnidexer(highestId + 1); const brew = await this.pGetBrewProcessed(); await [...Omnidexer.TO_INDEX__FROM_INDEX_JSON, ...Omnidexer.TO_INDEX] .filter(it => it.additionalIndexes && (brew[it.listProp] || []).length) .pMap(it => { Object.entries(it.additionalIndexes) .filter(([prop]) => prop === addiProp) .pMap(async ([, pGetIndex]) => { const toIndex = await pGetIndex(indexer, {[it.listProp]: brew[it.listProp]}); toIndex.forEach(add => indexer.pushToIndex(add)); }); }); return Omnidexer.decompressIndex(indexer.getIndex()); } async pGetAlternateSearchIndices (highestId, altProp) { const indexer = new Omnidexer(highestId + 1); const brew = await this.pGetBrewProcessed(); await [...Omnidexer.TO_INDEX__FROM_INDEX_JSON, ...Omnidexer.TO_INDEX] .filter(ti => ti.alternateIndexes && (brew[ti.listProp] || []).length) .pSerialAwaitMap(async arbiter => { await Object.keys(arbiter.alternateIndexes) .filter(prop => prop === altProp) .pSerialAwaitMap(async prop => { await indexer.pAddToIndex(arbiter, brew, {alt: arbiter.alternateIndexes[prop]}); }); }); return Omnidexer.decompressIndex(indexer.getIndex()); } // endregion // region Export to URL async pGetUrlExportableSources () { const brews = await this._pGetBrewRaw(); const brewsExportable = brews .filter(brew => !brew.head.isEditable && !brew.head.isLocal); return brewsExportable.flatMap(brew => brew.body._meta.sources.map(src => src.json)).unique(); } // endregion }