Files
5etools-mirror-2.github.io/js/utils-brew.js
TheGiddyLimit a4e391a3e7 v1.199.0
2024-01-25 23:07:09 +00:00

3555 lines
110 KiB
JavaScript

"use strict";
class _BrewInternalUtil {
static SOURCE_UNKNOWN_FULL = "(Unknown)";
static SOURCE_UNKNOWN_ABBREVIATION = "(UNK)";
}
class BrewDoc {
constructor (opts) {
opts = opts || {};
this.head = opts.head;
this.body = opts.body;
}
toObject () { return MiscUtil.copyFast({...this}); }
static fromValues ({head, body}) {
return new this({
head: BrewDocHead.fromValues(head),
body,
});
}
static fromObject (obj, opts = {}) {
const {isCopy = false} = opts;
return new this({
head: BrewDocHead.fromObject(obj.head, opts),
body: isCopy ? MiscUtil.copyFast(obj.body) : obj.body,
});
}
mutUpdate ({json}) {
this.body = json;
this.head.mutUpdate({json, body: this.body});
return this;
}
// region Conditions
static isOperationPermitted_moveToEditable ({brew, isAllowLocal = false} = {}) {
return !brew.head.isEditable
&& (isAllowLocal || !brew.head.isLocal);
}
// endregion
// region Merging
mutMerge ({json, isLazy = false}) {
this.body = this.constructor.mergeObjects({isCopy: !isLazy, isMutMakeCompatible: false}, this.body, json);
this.head.mutMerge({json, body: this.body, isLazy});
return this;
}
static mergeObjects ({isCopy = true, isMutMakeCompatible = true} = {}, ...jsons) {
const out = {};
jsons.forEach(json => {
json = isCopy ? MiscUtil.copyFast(json) : json;
if (isMutMakeCompatible) this._mergeObjects_mutMakeCompatible(json);
Object.entries(json)
.forEach(([prop, val]) => {
switch (prop) {
case "_meta": return this._mergeObjects_key__meta({out, prop, val});
default: return this._mergeObjects_default({out, prop, val});
}
});
});
return out;
}
static _META_KEYS_MERGEABLE_OBJECTS = [
"skills",
"senses",
"spellSchools",
"spellDistanceUnits",
"optionalFeatureTypes",
"psionicTypes",
"currencyConversions",
];
static _META_KEYS_MERGEABLE_SPECIAL = {
"dateAdded": (a, b) => a != null && b != null ? Math.min(a, b) : a ?? b,
"dateLastModified": (a, b) => a != null && b != null ? Math.max(a, b) : a ?? b,
"dependencies": (a, b) => this._metaMerge_dependenciesIncludes(a, b),
"includes": (a, b) => this._metaMerge_dependenciesIncludes(a, b),
"internalCopies": (a, b) => [...(a || []), ...(b || [])].unique(),
"otherSources": (a, b) => this._metaMerge_otherSources(a, b),
"status": (a, b) => this._metaMerge_status(a, b),
};
static _metaMerge_dependenciesIncludes (a, b) {
if (a != null && b != null) {
Object.entries(b)
.forEach(([prop, arr]) => a[prop] = [...(a[prop] || []), ...arr].unique());
return a;
}
return a ?? b;
}
static _metaMerge_otherSources (a, b) {
if (a != null && b != null) {
// Note that this can clobber the values in the mapping, but we don't really care since they're not used.
Object.entries(b)
.forEach(([prop, obj]) => a[prop] = Object.assign(a[prop] || {}, obj));
return a;
}
return a ?? b;
}
static _META_MERGE__STATUS_PRECEDENCE = [
"invalid",
"deprecated",
"wip",
"ready",
];
static _metaMerge_status (a, b) {
return [a || "ready", b || "ready"]
.sort((a, b) => this._META_MERGE__STATUS_PRECEDENCE.indexOf(a) - this._META_MERGE__STATUS_PRECEDENCE.indexOf(b))[0];
}
static _mergeObjects_key__meta ({out, val}) {
out._meta = out._meta || {};
out._meta.sources = [...(out._meta.sources || []), ...(val.sources || [])];
Object.entries(val)
.forEach(([metaProp, metaVal]) => {
if (this._META_KEYS_MERGEABLE_SPECIAL[metaProp]) {
out._meta[metaProp] = this._META_KEYS_MERGEABLE_SPECIAL[metaProp](out._meta[metaProp], metaVal);
return;
}
if (!this._META_KEYS_MERGEABLE_OBJECTS.includes(metaProp)) return;
Object.assign(out._meta[metaProp] = out._meta[metaProp] || {}, metaVal);
});
}
static _mergeObjects_default ({out, prop, val}) {
// If we cannot merge a prop, use the first value found for it, as a best-effort fallback
if (!(val instanceof Array)) return out[prop] === undefined ? out[prop] = val : null;
out[prop] = [...out[prop] || [], ...val];
}
static _mergeObjects_mutMakeCompatible (json) {
// region Item
if (json.variant) {
// 2022-07-09
json.magicvariant = json.variant;
delete json.variant;
}
// endregion
// region Race
if (json.subrace) {
json.subrace.forEach(sr => {
if (!sr.race) return;
sr.raceName = sr.race.name;
sr.raceSource = sr.race.source || sr.source || Parser.SRC_PHB;
});
}
// endregion
// region Creature (monster)
if (json.monster) {
json.monster.forEach(mon => {
// 2022-03-22
if (typeof mon.size === "string") mon.size = [mon.size];
// 2022=05-29
if (mon.summonedBySpell && !mon.summonedBySpellLevel) mon.summonedBySpellLevel = 1;
});
}
// endregion
// region Object
if (json.object) {
json.object.forEach(obj => {
// 2023-10-07
if (typeof obj.size === "string") obj.size = [obj.size];
});
}
// endregion
}
// endregion
}
class BrewDocHead {
constructor (opts) {
opts = opts || {};
this.docIdLocal = opts.docIdLocal;
this.timeAdded = opts.timeAdded;
this.checksum = opts.checksum;
this.url = opts.url;
this.filename = opts.filename;
this.isLocal = opts.isLocal;
this.isEditable = opts.isEditable;
}
toObject () { return MiscUtil.copyFast({...this}); }
static fromValues (
{
json,
url = null,
filename = null,
isLocal = false,
isEditable = false,
},
) {
return new this({
docIdLocal: CryptUtil.uid(),
timeAdded: Date.now(),
checksum: CryptUtil.md5(JSON.stringify(json)),
url: url,
filename: filename,
isLocal: isLocal,
isEditable: isEditable,
});
}
static fromObject (obj, {isCopy = false} = {}) {
return new this(isCopy ? MiscUtil.copyFast(obj) : obj);
}
mutUpdate ({json}) {
this.checksum = CryptUtil.md5(JSON.stringify(json));
return this;
}
mutMerge ({json, body, isLazy}) {
if (!isLazy) this.checksum = CryptUtil.md5(JSON.stringify(body ?? json));
return this;
}
}
class BrewUtilShared {
/** Prevent any injection shenanigans */
static getValidColor (color, {isExtended = false} = {}) {
if (isExtended) return color.replace(/[^-a-zA-Z\d]/g, "");
return color.replace(/[^a-fA-F\d]/g, "").slice(0, 8);
}
}
globalThis.BrewUtilShared = BrewUtilShared;
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; }
_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, lockToken}) {
try {
lockToken = await this._LOCK.pLock({token: lockToken});
return (await this._pGetBrewDependencies_({brewDocs, brewsRaw, brewsRawLocal, lockToken}));
} finally {
this._LOCK.unlock();
}
}
async _pGetBrewDependencies_ ({brewDocs, brewsRaw = null, brewsRawLocal = null, lockToken}) {
const urlRoot = await this.pGetCustomUrl();
const brewIndex = await this.pGetSourceIndex(urlRoot);
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});
const trackLoaded = brew => (brew.body._meta?.sources || [])
.filter(src => src.json)
.forEach(src => loadedSources.add(src.json));
brewsRaw.forEach(brew => trackLoaded(brew));
brewsRawLocal.forEach(brew => trackLoaded(brew));
brewDocs.forEach(brewDoc => {
const {available, unavailable} = this._getBrewDependencySources({brewDoc, brewIndex});
toLoadSources.push(...available);
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);
trackLoaded(brewDocDep);
const {available, unavailable} = this._getBrewDependencySources({brewDoc: brewDocDep, brewIndex});
toLoadSources.push(...available);
unavailable.forEach(src => unavailableSources.add(src));
}
return {
brewDocsDependencies,
unavailableSources: [...unavailableSources].sort(SortUtil.ascSortLower),
};
}
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!"); }
_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, 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()}<span class="glyphicon glyphicon-refresh rd__loadbrew-icon rd__loadbrew-icon--active"></span>`);
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()}<span class="glyphicon glyphicon-saved rd__loadbrew-icon"></span>`);
setTimeout(() => $ele.html(cached).addClass("rd__wrp-loadbrew--ready").title(cachedTitle), 500);
}
_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 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"); }
// 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 this._getColorStylePart(color);
return "";
}
sourceToStylePart (source) {
if (!source) return "";
const color = this.sourceToColor(source);
if (color) return this._getColorStylePart(color);
return "";
}
_getColorStylePart (color) { return `color: #${color} !important; border-color: #${color} !important; text-decoration-color: #${color} !important;`; }
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} = {}) {
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);
return;
}
await indexer.pAddToIndex(arbiter, brew);
});
return Omnidexer.decompressIndex(indexer.getIndex());
}
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
}
class _PrereleaseUtil extends _BrewUtil2Base {
_STORAGE_KEY_LEGACY = null;
_STORAGE_KEY_LEGACY_META = null;
_STORAGE_KEY = "PRERELEASE_STORAGE";
_STORAGE_KEY_META = "PRERELEASE_META_STORAGE";
_STORAGE_KEY_CUSTOM_URL = "PRERELEASE_CUSTOM_REPO_URL";
_STORAGE_KEY_MIGRATION_VERSION = "PRERELEASE_STORAGE_MIGRATION";
_PATH_LOCAL_DIR = "prerelease";
_PATH_LOCAL_INDEX = VeCt.JSON_PRERELEASE_INDEX;
_VERSION = 1;
IS_EDITABLE = false;
PAGE_MANAGE = UrlUtil.PG_MANAGE_PRERELEASE;
URL_REPO_DEFAULT = VeCt.URL_PRERELEASE;
URL_REPO_ROOT_DEFAULT = VeCt.URL_ROOT_PRERELEASE;
DISPLAY_NAME = "prerelease content";
DISPLAY_NAME_PLURAL = "prereleases";
DEFAULT_AUTHOR = "Wizards of the Coast";
STYLE_BTN = "btn-primary";
IS_PREFER_DATE_ADDED = false;
/* -------------------------------------------- */
_pInit_doBindDragDrop () { /* No-op */ }
/* -------------------------------------------- */
async pGetSourceIndex (urlRoot) { return DataUtil.prerelease.pLoadSourceIndex(urlRoot); }
getFileUrl (path, urlRoot) { return DataUtil.prerelease.getFileUrl(path, urlRoot); }
pLoadTimestamps (urlRoot) { return DataUtil.prerelease.pLoadTimestamps(urlRoot); }
pLoadPropIndex (urlRoot) { return DataUtil.prerelease.pLoadPropIndex(urlRoot); }
pLoadMetaIndex (urlRoot) { return DataUtil.prerelease.pLoadMetaIndex(urlRoot); }
/* -------------------------------------------- */
// region Editable
pGetEditableBrewDoc (brew) { return super.pGetEditableBrewDoc(brew); }
pGetOrCreateEditableBrewDoc () { return super.pGetOrCreateEditableBrewDoc(); }
pSetEditableBrewDoc () { return super.pSetEditableBrewDoc(); }
pGetEditableBrewEntity (prop, uniqueId, {isDuplicate = false} = {}) { return super.pGetEditableBrewEntity(prop, uniqueId, {isDuplicate}); }
pPersistEditableBrewEntity (prop, ent) { return super.pPersistEditableBrewEntity(prop, ent); }
pRemoveEditableBrewEntity (prop, uniqueId) { return super.pRemoveEditableBrewEntity(prop, uniqueId); }
pAddSource (sourceObj) { return super.pAddSource(sourceObj); }
pEditSource (sourceObj) { return super.pEditSource(sourceObj); }
pIsEditableSourceJson (sourceJson) { return super.pIsEditableSourceJson(sourceJson); }
pMoveOrCopyToEditableBySourceJson (sourceJson) { return super.pMoveOrCopyToEditableBySourceJson(sourceJson); }
pMoveToEditable ({brews}) { return super.pMoveToEditable({brews}); }
pCopyToEditable ({brews}) { return super.pCopyToEditable({brews}); }
// endregion
}
class _BrewUtil2 extends _BrewUtil2Base {
_STORAGE_KEY_LEGACY = "HOMEBREW_STORAGE";
_STORAGE_KEY_LEGACY_META = "HOMEBREW_META_STORAGE";
// Keep these distinct from the OG brew key, so users can recover their old brew if required.
_STORAGE_KEY = "HOMEBREW_2_STORAGE";
_STORAGE_KEY_META = "HOMEBREW_2_STORAGE_METAS";
_STORAGE_KEY_CUSTOM_URL = "HOMEBREW_CUSTOM_REPO_URL";
_STORAGE_KEY_MIGRATION_VERSION = "HOMEBREW_2_STORAGE_MIGRATION";
_VERSION = 2;
_PATH_LOCAL_DIR = "homebrew";
_PATH_LOCAL_INDEX = VeCt.JSON_BREW_INDEX;
IS_EDITABLE = true;
PAGE_MANAGE = UrlUtil.PG_MANAGE_BREW;
URL_REPO_DEFAULT = VeCt.URL_BREW;
URL_REPO_ROOT_DEFAULT = VeCt.URL_ROOT_BREW;
DISPLAY_NAME = "homebrew";
DISPLAY_NAME_PLURAL = "homebrews";
DEFAULT_AUTHOR = "";
STYLE_BTN = "btn-info";
IS_PREFER_DATE_ADDED = true;
/* -------------------------------------------- */
_pInit_doBindDragDrop () {
document.body.addEventListener("drop", async evt => {
if (EventUtil.isInInput(evt)) return;
evt.stopPropagation();
evt.preventDefault();
const files = evt.dataTransfer?.files;
if (!files?.length) return;
const pFiles = [...files].map((file, i) => {
if (!/\.json$/i.test(file.name)) return null;
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = () => {
let json;
try {
json = JSON.parse(reader.result);
} catch (ignored) {
return resolve(null);
}
resolve({name: file.name, json});
};
reader.readAsText(files[i]);
});
});
const fileMetas = (await Promise.allSettled(pFiles))
.filter(({status}) => status === "fulfilled")
.map(({value}) => value)
.filter(Boolean);
await this.pAddBrewsFromFiles(fileMetas);
if (this.isReloadRequired()) location.reload();
});
document.body.addEventListener("dragover", evt => {
if (EventUtil.isInInput(evt)) return;
evt.stopPropagation();
evt.preventDefault();
});
}
/* -------------------------------------------- */
async pGetSourceIndex (urlRoot) { return DataUtil.brew.pLoadSourceIndex(urlRoot); }
getFileUrl (path, urlRoot) { return DataUtil.brew.getFileUrl(path, urlRoot); }
pLoadTimestamps (urlRoot) { return DataUtil.brew.pLoadTimestamps(urlRoot); }
pLoadPropIndex (urlRoot) { return DataUtil.brew.pLoadPropIndex(urlRoot); }
pLoadMetaIndex (urlRoot) { return DataUtil.brew.pLoadMetaIndex(urlRoot); }
/* -------------------------------------------- */
// region Editable
async pGetEditableBrewDoc () {
return this._findEditableBrewDoc({brewRaw: await this._pGetBrewRaw()});
}
_findEditableBrewDoc ({brewRaw}) {
return brewRaw.find(it => it.head.isEditable);
}
async pGetOrCreateEditableBrewDoc () {
const existing = await this.pGetEditableBrewDoc();
if (existing) return existing;
const brew = this._getNewEditableBrewDoc();
const brews = [...MiscUtil.copyFast(await this._pGetBrewRaw()), brew];
await this.pSetBrew(brews);
return brew;
}
async pSetEditableBrewDoc (brew) {
if (!brew?.head?.docIdLocal || !brew?.body) throw new Error(`Invalid editable brew document!`); // Sanity check
await this.pUpdateBrew(brew);
}
/**
* @param prop
* @param uniqueId
* @param isDuplicate If the entity should be a duplicate, i.e. have a new `uniqueId`.
*/
async pGetEditableBrewEntity (prop, uniqueId, {isDuplicate = false} = {}) {
if (!uniqueId) throw new Error(`A "uniqueId" must be provided!`);
const brew = await this.pGetOrCreateEditableBrewDoc();
const out = (brew.body?.[prop] || []).find(it => it.uniqueId === uniqueId);
if (!out || !isDuplicate) return out;
if (isDuplicate) out.uniqueId = CryptUtil.uid();
return out;
}
async pPersistEditableBrewEntity (prop, ent) {
if (!ent.uniqueId) throw new Error(`Entity did not have a "uniqueId"!`);
const brew = await this.pGetOrCreateEditableBrewDoc();
const ixExisting = (brew.body?.[prop] || []).findIndex(it => it.uniqueId === ent.uniqueId);
if (!~ixExisting) {
const nxt = MiscUtil.copyFast(brew);
MiscUtil.getOrSet(nxt.body, prop, []).push(ent);
await this.pUpdateBrew(nxt);
return;
}
const nxt = MiscUtil.copyFast(brew);
nxt.body[prop][ixExisting] = ent;
await this.pUpdateBrew(nxt);
}
async pRemoveEditableBrewEntity (prop, uniqueId) {
if (!uniqueId) throw new Error(`A "uniqueId" must be provided!`);
const brew = await this.pGetOrCreateEditableBrewDoc();
if (!brew.body?.[prop]?.length) return;
const nxt = MiscUtil.copyFast(brew);
nxt.body[prop] = nxt.body[prop].filter(it => it.uniqueId !== uniqueId);
if (nxt.body[prop].length === brew.body[prop]) return; // Silently allow no-op deletes
await this.pUpdateBrew(nxt);
}
async pAddSource (sourceObj) {
const existing = await this.pGetEditableBrewDoc();
if (existing) {
const nxt = MiscUtil.copyFast(existing);
const sources = MiscUtil.getOrSet(nxt.body, "_meta", "sources", []);
sources.push(sourceObj);
await this.pUpdateBrew(nxt);
return;
}
const json = {_meta: {sources: [sourceObj]}};
const brew = this._getBrewDoc({json, isEditable: true});
const brews = [...MiscUtil.copyFast(await this._pGetBrewRaw()), brew];
await this.pSetBrew(brews);
}
async pEditSource (sourceObj) {
const existing = await this.pGetEditableBrewDoc();
if (!existing) throw new Error(`Editable brew document does not exist!`);
const nxt = MiscUtil.copyFast(existing);
const sources = MiscUtil.get(nxt.body, "_meta", "sources");
if (!sources) throw new Error(`Source "${sourceObj.json}" does not exist in editable brew document!`);
const existingSourceObj = sources.find(it => it.json === sourceObj.json);
if (!existingSourceObj) throw new Error(`Source "${sourceObj.json}" does not exist in editable brew document!`);
Object.assign(existingSourceObj, sourceObj);
await this.pUpdateBrew(nxt);
}
async pIsEditableSourceJson (sourceJson) {
const brew = await this.pGetEditableBrewDoc();
if (!brew) return false;
const sources = MiscUtil.get(brew.body, "_meta", "sources") || [];
return sources.some(it => it.json === sourceJson);
}
/**
* Move the brews containing a given source to the editable document. If a brew cannot be moved to the editable
* document, copy the source to the editable document instead.
*/
async pMoveOrCopyToEditableBySourceJson (sourceJson) {
if (await this.pIsEditableSourceJson(sourceJson)) return;
// Fetch all candidate brews
const brews = (await this._pGetBrewRaw()).filter(brew => (brew.body._meta?.sources || []).some(src => src.json === sourceJson));
const brewsLocal = (await this._pGetBrew_pGetLocalBrew()).filter(brew => (brew.body._meta?.sources || []).some(src => src.json === sourceJson));
// Arbitrarily select one, preferring non-local
let brew = brews.find(brew => BrewDoc.isOperationPermitted_moveToEditable({brew}));
if (!brew) brew = brewsLocal.find(brew => BrewDoc.isOperationPermitted_moveToEditable({brew, isAllowLocal: true}));
if (!brew) return;
if (brew.head.isLocal) return this.pCopyToEditable({brews: [brew]});
return this.pMoveToEditable({brews: [brew]});
}
async pMoveToEditable ({brews}) {
const out = await this.pCopyToEditable({brews});
await this.pDeleteBrews(brews);
return out;
}
async pCopyToEditable ({brews}) {
const brewEditable = await this.pGetOrCreateEditableBrewDoc();
const cpyBrewEditableDoc = BrewDoc.fromObject(brewEditable, {isCopy: true});
brews.forEach((brew, i) => cpyBrewEditableDoc.mutMerge({json: brew.body, isLazy: i !== brews.length - 1}));
await this.pSetEditableBrewDoc(cpyBrewEditableDoc.toObject());
return cpyBrewEditableDoc;
}
// endregion
}
globalThis.PrereleaseUtil = new _PrereleaseUtil();
globalThis.BrewUtil2 = new _BrewUtil2({parent: globalThis.PrereleaseUtil}); // Homebrew can depend on prerelease, but not the other way around
class ManageBrewUi {
static _RenderState = class {
constructor () {
this.$stgBrewList = null;
this.list = null;
this.listSelectClickHandler = null;
this.brews = [];
this.menuListMass = null;
this.rowMetas = [];
}
};
constructor ({brewUtil, isModal = false} = {}) {
this._brewUtil = brewUtil;
this._isModal = isModal;
}
static bindBtnOpen ($btn, {brewUtil = null} = {}) {
brewUtil = brewUtil || BrewUtil2;
$btn.click(evt => {
if (evt.shiftKey) return window.location = brewUtil.PAGE_MANAGE;
return this.pDoManageBrew({brewUtil});
});
}
static async pDoManageBrew ({brewUtil = null} = {}) {
brewUtil = brewUtil || BrewUtil2;
const ui = new this({isModal: true, brewUtil});
const rdState = new this._RenderState();
const {$modalInner} = UiUtil.getShowModal({
isHeight100: true,
isWidth100: true,
title: `Manage ${brewUtil.DISPLAY_NAME.toTitleCase()}`,
isUncappedHeight: true,
$titleSplit: $$`<div class="ve-flex-v-center btn-group">
${ui._$getBtnPullAll(rdState)}
${ui._$getBtnDeleteAll(rdState)}
</div>`,
isHeaderBorder: true,
cbClose: () => {
if (!brewUtil.isReloadRequired()) return;
window.location.hash = "";
location.reload();
},
});
await ui.pRender($modalInner, {rdState});
}
_$getBtnDeleteAll (rdState) {
return $(`<button class="btn btn-danger">Delete All</button>`)
.addClass(this._isModal ? "btn-xs" : "btn-sm")
.click(async () => {
if (!await InputUiUtil.pGetUserBoolean({title: `Delete All ${this._brewUtil.DISPLAY_NAME.toTitleCase()}`, htmlDescription: "Are you sure?", textYes: "Yes", textNo: "Cancel"})) return;
await this._pDoDeleteAll(rdState);
});
}
_$getBtnPullAll (rdState) {
const $btn = $(`<button class="btn btn-default">Update All</button>`)
.addClass(this._isModal ? "btn-xs w-70p" : "btn-sm w-80p")
.click(async () => {
const cachedHtml = $btn.html();
try {
$btn.text(`Updating...`).prop("disabled", true);
await this._pDoPullAll({rdState});
} catch (e) {
$btn.text(`Failed!`);
setTimeout(() => $btn.html(cachedHtml).prop("disabled", false), VeCt.DUR_INLINE_NOTIFY);
throw e;
}
$btn.text(`Done!`);
setTimeout(() => $btn.html(cachedHtml).prop("disabled", false), VeCt.DUR_INLINE_NOTIFY);
});
return $btn;
}
async _pDoDeleteAll (rdState) {
await this._brewUtil.pSetBrew([]);
rdState.list.removeAllItems();
rdState.list.update();
}
async _pDoPullAll ({rdState, brews = null}) {
if (brews && !brews.length) return;
let cntPulls;
try {
cntPulls = await this._brewUtil.pPullAllBrews({brews});
} catch (e) {
JqueryUtil.doToast({content: `Update failed! ${VeCt.STR_SEE_CONSOLE}`, type: "danger"});
throw e;
}
if (!cntPulls) return JqueryUtil.doToast(`Update complete! No ${this._brewUtil.DISPLAY_NAME} was updated.`);
await this._pRender_pBrewList(rdState);
JqueryUtil.doToast(`Update complete! ${cntPulls} ${cntPulls === 1 ? `${this._brewUtil.DISPLAY_NAME} was` : `${this._brewUtil.DISPLAY_NAME_PLURAL} were`} updated.`);
}
async pRender ($wrp, {rdState = null} = {}) {
rdState = rdState || new this.constructor._RenderState();
rdState.$stgBrewList = $(`<div class="manbrew__current_brew ve-flex-col h-100 mt-1 min-h-0"></div>`);
await this._pRender_pBrewList(rdState);
const $btnLoadFromFile = $(`<button class="btn btn-default btn-sm">Load from File</button>`)
.click(() => this._pHandleClick_btnLoadFromFile(rdState));
const $btnLoadFromUrl = $(`<button class="btn btn-default btn-sm">Load from URL</button>`)
.click(() => this._pHandleClick_btnLoadFromUrl(rdState));
const $btnGet = $(`<button class="btn ${this._brewUtil.STYLE_BTN} btn-sm">Get ${this._brewUtil.DISPLAY_NAME.toTitleCase()}</button>`)
.click(() => this._pHandleClick_btnGetBrew(rdState));
const $btnCustomUrl = $(`<button class="btn ${this._brewUtil.STYLE_BTN} btn-sm px-2" title="Set Custom Repository URL"><span class="glyphicon glyphicon-cog"></span></button>`)
.click(() => this._pHandleClick_btnSetCustomRepo());
const $btnPullAll = this._isModal ? null : this._$getBtnPullAll(rdState);
const $btnDeleteAll = this._isModal ? null : this._$getBtnDeleteAll(rdState);
const $btnSaveToUrl = $(`<button class="btn btn-default btn-sm" title="Note that this does not include &quot;Editable&quot; or &quot;Local&quot; content.">Export List as URL</button>`)
.click(async () => {
const url = await ManageExternalUtils.pGetUrl();
await MiscUtil.pCopyTextToClipboard(url);
JqueryUtil.showCopiedEffect($btnSaveToUrl);
});
const $wrpBtns = $$`<div class="ve-flex-v-center no-shrink mobile__ve-flex-col">
<div class="ve-flex-v-center mobile__mb-2">
<div class="ve-flex-v-center btn-group mr-2">
${$btnGet}
${$btnCustomUrl}
</div>
<div class="ve-flex-v-center btn-group mr-2">
${$btnLoadFromFile}
${$btnLoadFromUrl}
</div>
<div class="ve-flex-v-center btn-group mr-2">
${$btnSaveToUrl}
</div>
</div>
<div class="ve-flex-v-center">
<a href="${this._brewUtil.URL_REPO_DEFAULT}" class="ve-flex-v-center" target="_blank" rel="noopener noreferrer"><button class="btn btn-default btn-sm mr-2">Browse Source Repository</button></a>
<div class="ve-flex-v-center btn-group">
${$btnPullAll}
${$btnDeleteAll}
</div>
</div>
</div>`;
if (this._isModal) {
$$($wrp)`
${rdState.$stgBrewList}
${$wrpBtns.addClass("mb-2")}`;
} else {
$$($wrp)`
${$wrpBtns.addClass("mb-3")}
${rdState.$stgBrewList}`;
}
}
async _pHandleClick_btnLoadFromFile (rdState) {
const {files, errors} = await DataUtil.pUserUpload({isMultiple: true});
DataUtil.doHandleFileLoadErrorsGeneric(errors);
await this._brewUtil.pAddBrewsFromFiles(files);
await this._pRender_pBrewList(rdState);
}
async _pHandleClick_btnLoadFromUrl (rdState) {
const enteredUrl = await InputUiUtil.pGetUserString({title: `${this._brewUtil.DISPLAY_NAME.toTitleCase()} URL`});
if (!enteredUrl || !enteredUrl.trim()) return;
const parsedUrl = this.constructor._getParsedCustomUrl(enteredUrl);
if (!parsedUrl) {
return JqueryUtil.doToast({
content: `The URL was not valid!`,
type: "danger",
});
}
await this._brewUtil.pAddBrewFromUrl(parsedUrl.href);
await this._pRender_pBrewList(rdState);
}
static _getParsedCustomUrl (enteredUrl) {
try {
return new URL(enteredUrl);
} catch (e) {
return null;
}
}
async _pHandleClick_btnGetBrew (rdState) {
await GetBrewUi.pDoGetBrew({brewUtil: this._brewUtil, isModal: this._isModal});
await this._pRender_pBrewList(rdState);
}
async _pHandleClick_btnSetCustomRepo () {
const customBrewUtl = await this._brewUtil.pGetCustomUrl();
const nxtUrl = await InputUiUtil.pGetUserString({
title: `${this._brewUtil.DISPLAY_NAME.toTitleCase()} Repository URL`,
$elePre: $(`<div>
<p>Leave blank to use the <a href="${this._brewUtil.URL_REPO_DEFAULT}" rel="noopener noreferrer" target="_blank">default ${this._brewUtil.DISPLAY_NAME} repo</a>.</p>
<div>Note that for GitHub URLs, the <code>raw.</code> URL must be used. For example, <code>${this._brewUtil.URL_REPO_ROOT_DEFAULT.replace(/TheGiddyLimit/g, "YourUsernameHere")}</code></div>
<hr class="hr-3">
</div>`),
default: customBrewUtl,
});
if (nxtUrl == null) return;
await this._brewUtil.pSetCustomUrl(nxtUrl);
}
async _pRender_pBrewList (rdState) {
rdState.$stgBrewList.empty();
rdState.rowMetas.splice(0, rdState.rowMetas.length)
.forEach(({menu}) => ContextUtil.deleteMenu(menu));
const $btnMass = $(`<button class="btn btn-default">Mass...</button>`)
.click(evt => this._pHandleClick_btnListMass({evt, rdState}));
const $iptSearch = $(`<input type="search" class="search manbrew__search form-control" placeholder="Search ${this._brewUtil.DISPLAY_NAME}...">`);
const $cbAll = $(`<input type="checkbox">`);
const $wrpList = $(`<div class="list-display-only max-h-unset smooth-scroll overflow-y-auto h-100 min-h-0 brew-list brew-list--target manbrew__list relative ve-flex-col w-100 mb-3"></div>`);
rdState.list = new List({
$iptSearch,
$wrpList,
isUseJquery: true,
isFuzzy: true,
sortByInitial: rdState.list ? rdState.list.sortBy : undefined,
sortDirInitial: rdState.list ? rdState.list.sortDir : undefined,
});
const $wrpBtnsSort = $$`<div class="filtertools manbrew__filtertools btn-group input-group input-group--bottom ve-flex no-shrink">
<label class="col-0-5 pr-0 btn btn-default btn-xs ve-flex-vh-center">${$cbAll}</label>
<button class="col-1 btn btn-default btn-xs" disabled>Type</button>
<button class="col-3 btn btn-default btn-xs" data-sort="source">Source</button>
<button class="col-3 btn btn-default btn-xs" data-sort="authors">Authors</button>
<button class="col-3 btn btn-default btn-xs" disabled>Origin</button>
<button class="col-1-5 btn btn-default btn-xs ve-grow" disabled>&nbsp;</button>
</div>`;
$$(rdState.$stgBrewList)`
<div class="ve-flex-col h-100">
<div class="input-group ve-flex-vh-center">
${$btnMass}
${$iptSearch}
</div>
${$wrpBtnsSort}
<div class="ve-flex w-100 h-100 min-h-0 relative">${$wrpList}</div>
</div>`;
rdState.listSelectClickHandler = new ListSelectClickHandler({list: rdState.list});
rdState.listSelectClickHandler.bindSelectAllCheckbox($cbAll);
SortUtil.initBtnSortHandlers($wrpBtnsSort, rdState.list);
rdState.brews = (await this._brewUtil.pGetBrew()).map(brew => this._pRender_getProcBrew(brew));
rdState.brews.forEach((brew, ix) => {
const meta = this._pRender_getLoadedRowMeta(rdState, brew, ix);
rdState.rowMetas.push(meta);
rdState.list.addItem(meta.listItem);
});
rdState.list.init();
$iptSearch.focus();
}
get _LBL_LIST_UPDATE () { return "Update"; }
get _LBL_LIST_MANAGE_CONTENTS () { return "Manage Contents"; }
get _LBL_LIST_EXPORT () { return "Export"; }
get _LBL_LIST_VIEW_JSON () { return "View JSON"; }
get _LBL_LIST_DELETE () { return "Delete"; }
get _LBL_LIST_MOVE_TO_EDITABLE () { return `Move to Editable ${this._brewUtil.DISPLAY_NAME.toTitleCase()} Document`; }
_initListMassMenu ({rdState}) {
if (rdState.menuListMass) return;
const getSelBrews = ({fnFilter = null} = {}) => {
const brews = rdState.list.items
.filter(li => li.data.cbSel.checked)
.map(li => rdState.brews[li.ix])
.filter(brew => fnFilter ? fnFilter(brew) : true);
if (!brews.length) JqueryUtil.doToast({content: `Please select some suitable ${this._brewUtil.DISPLAY_NAME_PLURAL} first!`, type: "warning"});
return brews;
};
rdState.menuListMass = ContextUtil.getMenu([
new ContextUtil.Action(
this._LBL_LIST_UPDATE,
async () => this._pDoPullAll({
rdState,
brews: getSelBrews(),
}),
),
new ContextUtil.Action(
this._LBL_LIST_EXPORT,
async () => {
for (const brew of getSelBrews()) await this._pRender_pDoDownloadBrew({brew});
},
),
this._brewUtil.IS_EDITABLE
? new ContextUtil.Action(
this._LBL_LIST_MOVE_TO_EDITABLE,
async () => this._pRender_pDoMoveToEditable({
rdState,
brews: getSelBrews({
fnFilter: brew => this._isBrewOperationPermitted_moveToEditable(brew),
}),
}),
)
: null,
new ContextUtil.Action(
this._LBL_LIST_DELETE,
async () => this._pRender_pDoDelete({
rdState,
brews: getSelBrews({
fnFilter: brew => this._isBrewOperationPermitted_delete(brew),
}),
}),
),
].filter(Boolean));
}
_isBrewOperationPermitted_update (brew) { return this._brewUtil.isPullable(brew); }
_isBrewOperationPermitted_moveToEditable (brew) { return BrewDoc.isOperationPermitted_moveToEditable({brew}); }
_isBrewOperationPermitted_delete (brew) { return !brew.head.isLocal; }
async _pHandleClick_btnListMass ({evt, rdState}) {
this._initListMassMenu({rdState});
await ContextUtil.pOpenMenu(evt, rdState.menuListMass);
}
static _getBrewName (brew) {
const sources = brew.body._meta?.sources || [];
return sources
.map(brewSource => brewSource.full || _BrewInternalUtil.SOURCE_UNKNOWN_FULL)
.sort(SortUtil.ascSortLower)
.join(", ");
}
_pRender_getLoadedRowMeta (rdState, brew, ix) {
const sources = brew.body._meta?.sources || [];
const rowsSubMetas = sources
.map(brewSource => {
const hasConverters = !!brewSource.convertedBy?.length;
const btnConvertedBy = e_({
tag: "button",
clazz: `btn btn-xxs btn-default ${!hasConverters ? "disabled" : ""}`,
title: hasConverters ? `Converted by: ${brewSource.convertedBy.join(", ").qq()}` : "(No conversion credit given)",
children: [
e_({tag: "span", clazz: "mobile__hidden", text: "View Converters"}),
e_({tag: "span", clazz: "mobile__visible", text: "Convs.", title: "View Converters"}),
],
click: () => {
if (!hasConverters) return;
const {$modalInner} = UiUtil.getShowModal({
title: `Converted By:${brewSource.convertedBy.length === 1 ? ` ${brewSource.convertedBy.join("")}` : ""}`,
isMinHeight0: true,
});
if (brewSource.convertedBy.length === 1) return;
$modalInner.append(`<ul>${brewSource.convertedBy.map(it => `<li>${it.qq()}</li>`).join("")}</ul>`);
},
});
const authorsFull = [(brewSource.authors || [])].flat(2).join(", ");
const lnkUrl = brewSource.url
? e_({
tag: "a",
clazz: "col-2 ve-text-center",
href: brewSource.url,
attrs: {
target: "_blank",
rel: "noopener noreferrer",
},
text: "View Source",
})
: e_({
tag: "span",
clazz: "col-2 ve-text-center",
});
const eleRow = e_({
tag: "div",
clazz: `w-100 ve-flex-v-center`,
children: [
e_({
tag: "span",
clazz: `col-4 manbrew__source px-1`,
text: brewSource.full,
}),
e_({
tag: "span",
clazz: `col-4 px-1`,
text: authorsFull,
}),
lnkUrl,
e_({
tag: "div",
clazz: `ve-flex-vh-center ve-grow`,
children: [
btnConvertedBy,
],
}),
],
});
return {
eleRow,
authorsFull,
name: brewSource.full || _BrewInternalUtil.SOURCE_UNKNOWN_FULL,
abbreviation: brewSource.abbreviation || _BrewInternalUtil.SOURCE_UNKNOWN_ABBREVIATION,
};
})
.sort((a, b) => SortUtil.ascSortLower(a.name, b.name));
const brewName = this.constructor._getBrewName(brew);
// region These are mutually exclusive
const btnPull = this._pRender_getBtnPull({rdState, brew});
const btnEdit = this._pRender_getBtnEdit({rdState, brew});
const btnPullEditPlaceholder = (btnPull || btnEdit) ? null : this.constructor._pRender_getBtnPlaceholder();
// endregion
const btnDownload = e_({
tag: "button",
clazz: `btn btn-default btn-xs mobile__hidden w-24p`,
title: this._LBL_LIST_EXPORT,
children: [
e_({
tag: "span",
clazz: "glyphicon glyphicon-download manbrew-row__icn-btn",
}),
],
click: () => this._pRender_pDoDownloadBrew({brew, brewName}),
});
const btnViewJson = e_({
tag: "button",
clazz: `btn btn-default btn-xs mobile-ish__hidden w-24p`,
title: `${this._LBL_LIST_VIEW_JSON}: ${this.constructor._getBrewJsonTitle({brew, brewName})}`,
children: [
e_({
tag: "span",
clazz: "ve-bolder code relative manbrew-row__icn-btn--text",
text: "{}",
}),
],
click: evt => this._pRender_doViewBrew({evt, brew, brewName}),
});
const btnOpenMenu = e_({
tag: "button",
clazz: `btn btn-default btn-xs w-24p`,
title: "Menu",
children: [
e_({
tag: "span",
clazz: "glyphicon glyphicon-option-vertical manbrew-row__icn-btn",
}),
],
click: evt => this._pRender_pDoOpenBrewMenu({evt, rdState, brew, brewName, rowMeta}),
});
const btnDelete = this._isBrewOperationPermitted_delete(brew) ? e_({
tag: "button",
clazz: `btn btn-danger btn-xs mobile__hidden w-24p`,
title: this._LBL_LIST_DELETE,
children: [
e_({
tag: "span",
clazz: "glyphicon glyphicon-trash manbrew-row__icn-btn",
}),
],
click: () => this._pRender_pDoDelete({rdState, brews: [brew]}),
}) : this.constructor._pRender_getBtnPlaceholder();
// Weave in HRs
const elesSub = rowsSubMetas.map(it => it.eleRow);
for (let i = rowsSubMetas.length - 1; i > 0; --i) elesSub.splice(i, 0, e_({tag: "hr", clazz: `hr-1 hr--dotted`}));
const cbSel = e_({
tag: "input",
clazz: "no-events",
type: "checkbox",
});
const ptCategory = brew.head.isLocal
? {short: `Local`, title: `Local Document`}
: brew.head.isEditable
? {short: `Editable`, title: `Editable Document`}
: {short: `Standard`, title: `Standard Document`};
const eleLi = e_({
tag: "div",
clazz: `manbrew__row ve-flex-v-center lst__row lst--border lst__row-inner no-shrink py-1 no-select`,
children: [
e_({
tag: "label",
clazz: `col-0-5 ve-flex-vh-center ve-self-flex-stretch`,
children: [cbSel],
}),
e_({
tag: "div",
clazz: `col-1 ve-text-center italic mobile__text-clip-ellipsis`,
title: ptCategory.title,
text: ptCategory.short,
}),
e_({
tag: "div",
clazz: `col-9 ve-flex-col`,
children: elesSub,
}),
e_({
tag: "div",
clazz: `col-1-5 btn-group ve-flex-vh-center`,
children: [
btnPull,
btnEdit,
btnPullEditPlaceholder,
btnDownload,
btnViewJson,
btnOpenMenu,
btnDelete,
],
}),
],
});
const listItem = new ListItem(
ix,
eleLi,
brewName,
{
authors: rowsSubMetas.map(it => it.authorsFull).join(", "),
abbreviation: rowsSubMetas.map(it => it.abbreviation).join(", "),
},
{
cbSel,
},
);
eleLi.addEventListener("click", evt => rdState.listSelectClickHandler.handleSelectClick(listItem, evt, {isPassThroughEvents: true}));
const rowMeta = {
listItem,
menu: null,
};
return rowMeta;
}
static _pRender_getBtnPlaceholder () {
return e_({
tag: "button",
clazz: `btn btn-default btn-xs mobile__hidden w-24p`,
html: "&nbsp;",
})
.attr("disabled", true);
}
_pRender_getBtnPull ({rdState, brew}) {
if (!this._isBrewOperationPermitted_update(brew)) return null;
const btnPull = e_({
tag: "button",
clazz: `btn btn-default btn-xs mobile__hidden w-24p`,
title: this._LBL_LIST_UPDATE,
children: [
e_({
tag: "span",
clazz: "glyphicon glyphicon-refresh manbrew-row__icn-btn",
}),
],
click: () => this._pRender_pDoPullBrew({rdState, brew}),
});
if (!this._brewUtil.isPullable(brew)) btnPull.attr("disabled", true).attr("title", `(Update disabled\u2014no URL available)`);
return btnPull;
}
_pRender_getBtnEdit ({rdState, brew}) {
if (!brew.head.isEditable) return null;
return e_({
tag: "button",
clazz: `btn btn-default btn-xs mobile__hidden w-24p`,
title: this._LBL_LIST_MANAGE_CONTENTS,
children: [
e_({
tag: "span",
clazz: "glyphicon glyphicon-pencil manbrew-row__icn-btn",
}),
],
click: () => this._pRender_pDoEditBrew({rdState, brew}),
});
}
async _pRender_pDoPullBrew ({rdState, brew}) {
const isPull = await this._brewUtil.pPullBrew(brew);
JqueryUtil.doToast(
isPull
? `${this._brewUtil.DISPLAY_NAME.uppercaseFirst()} updated!`
: `${this._brewUtil.DISPLAY_NAME.uppercaseFirst()} is already up-to-date.`,
);
if (!isPull) return;
await this._pRender_pBrewList(rdState);
}
async _pRender_pDoEditBrew ({rdState, brew}) {
const {isDirty, brew: nxtBrew} = await ManageEditableBrewContentsUi.pDoOpen({brewUtil: this._brewUtil, brew, isModal: this._isModal});
if (!isDirty) return;
await this._brewUtil.pUpdateBrew(nxtBrew);
await this._pRender_pBrewList(rdState);
}
async _pRender_pDoDownloadBrew ({brew, brewName = null}) {
const filename = (brew.head.filename || "").split(".").slice(0, -1).join(".");
// For the editable brew, if there are multiple sources, present the user with a selection screen. We then filter
// the editable brew down to whichever sources they selected.
const isChooseSources = brew.head.isEditable && (brew.body._meta?.sources || []).length > 1;
if (!isChooseSources) {
const outFilename = filename || brewName || this.constructor._getBrewName(brew);
const json = brew.head.isEditable ? MiscUtil.copyFast(brew.body) : brew.body;
this.constructor._mutExportableEditableData({json: json});
return DataUtil.userDownload(outFilename, json, {isSkipAdditionalMetadata: true});
}
// region Get chosen sources
const getSourceAsText = source => `[${(source.abbreviation || "").qq()}] ${(source.full || "").qq()}`;
const choices = await InputUiUtil.pGetUserMultipleChoice({
title: `Choose Sources`,
values: brew.body._meta.sources,
fnDisplay: getSourceAsText,
isResolveItems: true,
max: Number.MAX_SAFE_INTEGER,
isSearchable: true,
fnGetSearchText: getSourceAsText,
});
if (choices == null || choices.length === 0) return;
// endregion
// region Filter output by selected sources
const cpyBrew = MiscUtil.copyFast(brew.body);
const sourceAllowlist = new Set(choices.map(it => it.json));
cpyBrew._meta.sources = cpyBrew._meta.sources.filter(it => sourceAllowlist.has(it.json));
Object.entries(cpyBrew)
.forEach(([k, v]) => {
if (!v || !(v instanceof Array)) return;
if (k.startsWith("_")) return;
cpyBrew[k] = v.filter(it => {
const source = SourceUtil.getEntitySource(it);
if (!source) return true;
return sourceAllowlist.has(source);
});
});
// endregion
const reducedFilename = filename || this.constructor._getBrewName({body: cpyBrew});
this.constructor._mutExportableEditableData({json: cpyBrew});
return DataUtil.userDownload(reducedFilename, cpyBrew, {isSkipAdditionalMetadata: true});
}
/**
* The editable brew may contain `uniqueId` references from the builder, which should be stripped before export.
*/
static _mutExportableEditableData ({json}) {
Object.values(json)
.forEach(arr => {
if (arr == null || !(arr instanceof Array)) return;
arr.forEach(ent => delete ent.uniqueId);
});
return json;
}
static _getBrewJsonTitle ({brew, brewName}) {
brewName = brewName || this._getBrewName(brew);
return brew.head.filename || brewName;
}
_pRender_doViewBrew ({evt, brew, brewName}) {
const title = this.constructor._getBrewJsonTitle({brew, brewName});
const $content = Renderer.hover.$getHoverContent_statsCode(brew.body, {isSkipClean: true, title});
Renderer.hover.getShowWindow(
$content,
Renderer.hover.getWindowPositionFromEvent(evt),
{
title,
isPermanent: true,
isBookContent: true,
},
);
}
async _pRender_pDoOpenBrewMenu ({evt, rdState, brew, brewName, rowMeta}) {
rowMeta.menu = rowMeta.menu || this._pRender_getBrewMenu({rdState, brew, brewName});
await ContextUtil.pOpenMenu(evt, rowMeta.menu);
}
_pRender_getBrewMenu ({rdState, brew, brewName}) {
const menuItems = [];
if (this._isBrewOperationPermitted_update(brew)) {
menuItems.push(
new ContextUtil.Action(
this._LBL_LIST_UPDATE,
async () => this._pRender_pDoPullBrew({rdState, brew}),
),
);
} else if (brew.head.isEditable) {
menuItems.push(
new ContextUtil.Action(
this._LBL_LIST_MANAGE_CONTENTS,
async () => this._pRender_pDoEditBrew({rdState, brew}),
),
);
}
menuItems.push(
new ContextUtil.Action(
this._LBL_LIST_EXPORT,
async () => this._pRender_pDoDownloadBrew({brew, brewName}),
),
new ContextUtil.Action(
this._LBL_LIST_VIEW_JSON,
async evt => this._pRender_doViewBrew({evt, brew, brewName}),
),
);
if (this._brewUtil.IS_EDITABLE && this._isBrewOperationPermitted_moveToEditable(brew)) {
menuItems.push(
new ContextUtil.Action(
this._LBL_LIST_MOVE_TO_EDITABLE,
async () => this._pRender_pDoMoveToEditable({rdState, brews: [brew]}),
),
);
}
if (this._isBrewOperationPermitted_delete(brew)) {
menuItems.push(
new ContextUtil.Action(
this._LBL_LIST_DELETE,
async () => this._pRender_pDoDelete({rdState, brews: [brew]}),
),
);
}
return ContextUtil.getMenu(menuItems);
}
_pGetUserBoolean_isMoveBrewsToEditable ({brews}) {
return InputUiUtil.pGetUserBoolean({
title: `Move to Editable ${this._brewUtil.DISPLAY_NAME.toTitleCase()} Document`,
htmlDescription: `Moving ${brews.length === 1 ? `this ${this._brewUtil.DISPLAY_NAME}` : `these
${this._brewUtil.DISPLAY_NAME_PLURAL}`} to the editable document will prevent ${brews.length === 1 ? "it" : "them"} from being automatically updated in future.<br>Are you sure you want to move ${brews.length === 1 ? "it" : "them"}?`,
textYes: "Yes",
textNo: "Cancel",
});
}
async _pRender_pDoMoveToEditable ({rdState, brews}) {
if (!brews?.length) return;
if (!await this._pGetUserBoolean_isMoveBrewsToEditable({brews})) return;
await this._brewUtil.pMoveToEditable({brews});
await this._pRender_pBrewList(rdState);
JqueryUtil.doToast(`${`${brews.length === 1 ? this._brewUtil.DISPLAY_NAME : this._brewUtil.DISPLAY_NAME_PLURAL}`.uppercaseFirst()} moved to editable document!`);
}
_pGetUserBoolean_isDeleteBrews ({brews}) {
if (!brews.some(brew => brew.head.isEditable)) return true;
const htmlDescription = brews.length === 1
? `This document contains all your locally-created or edited ${this._brewUtil.DISPLAY_NAME_PLURAL}.<br>Are you sure you want to delete it?`
: `One of the documents you are about to delete contains all your locally-created or edited ${this._brewUtil.DISPLAY_NAME_PLURAL}.<br>Are you sure you want to delete these documents?`;
return InputUiUtil.pGetUserBoolean({
title: `Delete ${this._brewUtil.DISPLAY_NAME}`,
htmlDescription,
textYes: "Yes",
textNo: "Cancel",
});
}
async _pRender_pDoDelete ({rdState, brews}) {
if (!brews?.length) return;
if (!await this._pGetUserBoolean_isDeleteBrews({brews})) return;
await this._brewUtil.pDeleteBrews(brews);
await this._pRender_pBrewList(rdState);
}
_pRender_getProcBrew (brew) {
brew = MiscUtil.copyFast(brew);
brew.body._meta.sources.sort((a, b) => SortUtil.ascSortLower(a.full || "", b.full || ""));
return brew;
}
}
class GetBrewUi {
static _RenderState = class {
constructor () {
this.pageFilter = null;
this.list = null;
this.listSelectClickHandler = null;
this.cbAll = null;
}
};
static _TypeFilter = class extends Filter {
constructor ({brewUtil}) {
const pageProps = brewUtil.getPageProps({fallback: ["*"]});
super({
header: "Category",
items: [],
displayFn: brewUtil.getPropDisplayName.bind(brewUtil),
selFn: prop => pageProps.includes("*") || pageProps.includes(prop),
isSortByDisplayItems: true,
});
this._brewUtil = brewUtil;
}
_getHeaderControls_addExtraStateBtns (opts, wrpStateBtnsOuter) {
const menu = ContextUtil.getMenu(
this._brewUtil.getPropPages()
.map(page => ({page, displayPage: UrlUtil.pageToDisplayPage(page)}))
.sort(SortUtil.ascSortProp.bind(SortUtil, "displayPage"))
.map(({page, displayPage}) => {
return new ContextUtil.Action(
displayPage,
() => {
const propsActive = new Set(this._brewUtil.getPageProps({page, fallback: []}));
Object.keys(this._state).forEach(prop => this._state[prop] = propsActive.has(prop) ? 1 : 0);
},
);
}),
);
const btnPage = e_({
tag: "button",
clazz: `btn btn-default w-100 btn-xs`,
text: `Select for Page...`,
click: evt => ContextUtil.pOpenMenu(evt, menu),
});
e_({
tag: "div",
clazz: `btn-group mr-2 w-100 ve-flex-v-center`,
children: [
btnPage,
],
}).prependTo(wrpStateBtnsOuter);
}
};
static _PageFilterGetBrew = class extends PageFilter {
static _STATUS_FILTER_DEFAULT_DESELECTED = new Set(["wip", "deprecated", "invalid"]);
constructor ({brewUtil}) {
super();
this._brewUtil = brewUtil;
this._typeFilter = new GetBrewUi._TypeFilter({brewUtil});
this._statusFilter = new Filter({
header: "Status",
items: [
"ready",
"wip",
"deprecated",
"invalid",
],
displayFn: StrUtil.toTitleCase,
itemSortFn: null,
deselFn: it => this.constructor._STATUS_FILTER_DEFAULT_DESELECTED.has(it),
});
this._miscFilter = new Filter({
header: "Miscellaneous",
items: ["Sample"],
deselFn: it => it === "Sample",
});
}
static mutateForFilters (brewInfo) {
brewInfo._fMisc = [];
if (brewInfo._brewAuthor && brewInfo._brewAuthor.toLowerCase().startsWith("sample -")) brewInfo._fMisc.push("Sample");
if (brewInfo.sources?.some(ab => ab.startsWith(Parser.SRC_UA_ONE_PREFIX))) brewInfo._fMisc.push("One D&D");
}
addToFilters (it, isExcluded) {
if (isExcluded) return;
this._typeFilter.addItem(it.props);
this._miscFilter.addItem(it._fMisc);
}
async _pPopulateBoxOptions (opts) {
opts.filters = [
this._typeFilter,
this._statusFilter,
this._miscFilter,
];
}
toDisplay (values, it) {
return this._filterBox.toDisplay(
values,
it.props,
it._brewStatus,
it._fMisc,
);
}
};
static async pDoGetBrew ({brewUtil, isModal: isParentModal = false} = {}) {
return new Promise((resolve, reject) => {
const ui = new this({brewUtil, isModal: true});
const rdState = new this._RenderState();
const {$modalInner} = UiUtil.getShowModal({
isHeight100: true,
title: `Get ${brewUtil.DISPLAY_NAME.toTitleCase()}`,
isUncappedHeight: true,
isWidth100: true,
overlayColor: isParentModal ? "transparent" : undefined,
isHeaderBorder: true,
cbClose: async () => {
await ui.pHandlePreCloseModal({rdState});
resolve([...ui._brewsLoaded]);
},
});
ui.pInit()
.then(() => ui.pRender($modalInner, {rdState}))
.catch(e => reject(e));
});
}
_sortUrlList (a, b, o) {
a = this._dataList[a.ix];
b = this._dataList[b.ix];
switch (o.sortBy) {
case "name": return this.constructor._sortUrlList_byName(a, b);
case "author": return this.constructor._sortUrlList_orFallback(a, b, SortUtil.ascSortLower, "_brewAuthor");
case "category": return this.constructor._sortUrlList_orFallback(a, b, SortUtil.ascSortLower, "_brewPropDisplayName");
case "added": return this.constructor._sortUrlList_orFallback(a, b, SortUtil.ascSort, "_brewAdded");
case "modified": return this.constructor._sortUrlList_orFallback(a, b, SortUtil.ascSort, "_brewModified");
case "published": return this.constructor._sortUrlList_orFallback(a, b, SortUtil.ascSort, "_brewPublished");
default: throw new Error(`No sort order defined for property "${o.sortBy}"`);
}
}
static _sortUrlList_byName (a, b) { return SortUtil.ascSortLower(a._brewName, b._brewName); }
static _sortUrlList_orFallback (a, b, fn, prop) { return fn(a[prop], b[prop]) || this._sortUrlList_byName(a, b); }
constructor ({brewUtil, isModal} = {}) {
this._brewUtil = brewUtil;
this._isModal = isModal;
this._dataList = null;
this._brewsLoaded = []; // Track the brews we load during our lifetime
}
async pInit () {
const urlRoot = await this._brewUtil.pGetCustomUrl();
const [timestamps, propIndex, metaIndex, sourceIndex] = await Promise.all([
this._brewUtil.pLoadTimestamps(urlRoot),
this._brewUtil.pLoadPropIndex(urlRoot),
this._brewUtil.pLoadMetaIndex(urlRoot),
this._brewUtil.pGetSourceIndex(urlRoot),
]);
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);
});
this._dataList = Object.entries(pathToMeta)
.map(([path, meta]) => {
const out = {
download_url: this._brewUtil.getFileUrl(path, urlRoot),
path,
name: UrlUtil.getFilename(path),
dirProp: this._brewUtil.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._brewUtil.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._brewPropDisplayName = this._brewUtil.getPropDisplayName(out.dirProp);
return out;
})
.sort((a, b) => SortUtil.ascSortLower(a._brewName, b._brewName));
}
async pHandlePreCloseModal ({rdState}) {
// region If the user has selected list items, prompt to load them before closing the modal
const cntSel = rdState.list.items.filter(it => it.data.cbSel.checked).length;
if (!cntSel) return;
const isSave = await InputUiUtil.pGetUserBoolean({
title: `Selected ${this._brewUtil.DISPLAY_NAME}`,
htmlDescription: `You have ${cntSel} ${cntSel === 1 ? this._brewUtil.DISPLAY_NAME : this._brewUtil.DISPLAY_NAME_PLURAL} selected which ${cntSel === 1 ? "is" : "are"} not yet loaded. Would you like to load ${cntSel === 1 ? "it" : "them"}?`,
textYes: "Load",
textNo: "Discard",
});
if (!isSave) return;
await this._pHandleClick_btnAddSelected({rdState});
// endregion
}
async pRender ($wrp, {rdState} = {}) {
rdState = rdState || new this.constructor._RenderState();
rdState.pageFilter = new this.constructor._PageFilterGetBrew({brewUtil: this._brewUtil});
const $btnAddSelected = $(`<button class="btn ${this._brewUtil.STYLE_BTN} btn-sm col-0-5 ve-text-center" disabled title="Add Selected"><span class="glyphicon glyphicon-save"></button>`);
const $wrpRows = $$`<div class="list smooth-scroll max-h-unset"><div class="lst__row ve-flex-col"><div class="lst__wrp-cells lst--border lst__row-inner ve-flex w-100"><i>Loading...</i></div></div></div>`;
const $btnFilter = $(`<button class="btn btn-default btn-sm">Filter</button>`);
const $btnToggleSummaryHidden = $(`<button class="btn btn-default" title="Toggle Filter Summary Display"><span class="glyphicon glyphicon-resize-small"></span></button>`);
const $iptSearch = $(`<input type="search" class="search manbrew__search form-control w-100 lst__search lst__search--no-border-h" placeholder="Find ${this._brewUtil.DISPLAY_NAME}...">`)
.keydown(evt => this._pHandleKeydown_iptSearch(evt, rdState));
const $dispCntVisible = $(`<div class="lst__wrp-search-visible no-events ve-flex-vh-center"></div>`);
rdState.cbAll = e_({
tag: "input",
type: "checkbox",
});
const $btnReset = $(`<button class="btn btn-default btn-sm">Reset</button>`);
const $wrpMiniPills = $(`<div class="fltr__mini-view btn-group"></div>`);
const btnSortAddedPublished = this._brewUtil.IS_PREFER_DATE_ADDED
? `<button class="col-1-4 sort btn btn-default btn-xs" data-sort="added">Added</button>`
: `<button class="col-1-4 sort btn btn-default btn-xs" data-sort="published">Published</button>`;
const $wrpSort = $$`<div class="filtertools manbrew__filtertools btn-group input-group input-group--bottom ve-flex no-shrink">
<label class="col-0-5 pr-0 btn btn-default btn-xs ve-flex-vh-center">${rdState.cbAll}</label>
<button class="col-3-5 sort btn btn-default btn-xs" data-sort="name">Name</button>
<button class="col-3 sort btn btn-default btn-xs" data-sort="author">Author</button>
<button class="col-1-2 sort btn btn-default btn-xs" data-sort="category">Category</button>
<button class="col-1-4 sort btn btn-default btn-xs" data-sort="modified">Modified</button>
${btnSortAddedPublished}
<button class="sort btn btn-default btn-xs ve-grow" disabled>Source</button>
</div>`;
$$($wrp)`
<div class="mt-1"><i>A list of ${this._brewUtil.DISPLAY_NAME} available in the public repository. Click a name to load the ${this._brewUtil.DISPLAY_NAME}, or view the source directly.${this._brewUtil.IS_EDITABLE ? `<br>
Contributions are welcome; see the <a href="${this._brewUtil.URL_REPO_DEFAULT}/blob/master/README.md" target="_blank" rel="noopener noreferrer">README</a>, or stop by our <a href="https://discord.gg/5etools" target="_blank" rel="noopener noreferrer">Discord</a>.` : ""}</i></div>
<hr class="hr-3">
<div class="lst__form-top">
${$btnAddSelected}
${$btnFilter}
${$btnToggleSummaryHidden}
<div class="w-100 relative">
${$iptSearch}
<div id="lst__search-glass" class="lst__wrp-search-glass no-events ve-flex-vh-center"><span class="glyphicon glyphicon-search"></span></div>
${$dispCntVisible}
</div>
${$btnReset}
</div>
${$wrpMiniPills}
${$wrpSort}
${$wrpRows}`;
rdState.list = new List({
$iptSearch,
$wrpList: $wrpRows,
fnSort: this._sortUrlList.bind(this),
isUseJquery: true,
isFuzzy: true,
isSkipSearchKeybindingEnter: true,
});
rdState.list.on("updated", () => $dispCntVisible.html(`${rdState.list.visibleItems.length}/${rdState.list.items.length}`));
rdState.listSelectClickHandler = new ListSelectClickHandler({list: rdState.list});
rdState.listSelectClickHandler.bindSelectAllCheckbox($(rdState.cbAll));
SortUtil.initBtnSortHandlers($wrpSort, rdState.list);
this._dataList.forEach((brewInfo, ix) => {
const {listItem} = this._pRender_getUrlRowMeta(rdState, brewInfo, ix);
rdState.list.addItem(listItem);
});
await rdState.pageFilter.pInitFilterBox({
$iptSearch: $iptSearch,
$btnReset: $btnReset,
$btnOpen: $btnFilter,
$btnToggleSummaryHidden,
$wrpMiniPills,
namespace: `get-homebrew-${UrlUtil.getCurrentPage()}`,
});
this._dataList.forEach(it => rdState.pageFilter.mutateAndAddToFilters(it));
rdState.list.init();
rdState.pageFilter.trimState();
rdState.pageFilter.filterBox.render();
rdState.pageFilter.filterBox.on(
FilterBox.EVNT_VALCHANGE,
this._handleFilterChange.bind(this, rdState),
);
this._handleFilterChange(rdState);
$btnAddSelected
.prop("disabled", false)
.click(() => this._pHandleClick_btnAddSelected({rdState}));
$iptSearch.focus();
}
_handleFilterChange (rdState) {
const f = rdState.pageFilter.filterBox.getValues();
rdState.list.filter(li => rdState.pageFilter.toDisplay(f, this._dataList[li.ix]));
}
_pRender_getUrlRowMeta (rdState, brewInfo, ix) {
const epochAddedPublished = this._brewUtil.IS_PREFER_DATE_ADDED ? brewInfo._brewAdded : brewInfo._brewPublished;
const timestampAddedPublished = epochAddedPublished
? DatetimeUtil.getDateStr({date: new Date(epochAddedPublished * 1000), isShort: true, isPad: true})
: "";
const timestampModified = brewInfo._brewModified
? DatetimeUtil.getDateStr({date: new Date(brewInfo._brewModified * 1000), isShort: true, isPad: true})
: "";
const cbSel = e_({
tag: "input",
clazz: "no-events",
type: "checkbox",
});
const btnAdd = e_({
tag: "span",
clazz: `col-3-5 bold manbrew__load_from_url pl-0 clickable`,
text: brewInfo._brewName,
click: evt => this._pHandleClick_btnGetRemote({evt, btn: btnAdd, url: brewInfo.download_url}),
});
const eleLi = e_({
tag: "div",
clazz: `lst__row lst__row-inner not-clickable lst--border lst__row--focusable no-select`,
children: [
e_({
tag: "div",
clazz: `lst__wrp-cells ve-flex w-100`,
children: [
e_({
tag: "label",
clazz: `col-0-5 ve-flex-vh-center ve-self-flex-stretch`,
children: [cbSel],
}),
btnAdd,
e_({tag: "span", clazz: "col-3", text: brewInfo._brewAuthor}),
e_({tag: "span", clazz: "col-1-2 ve-text-center mobile__text-clip-ellipsis", text: brewInfo._brewPropDisplayName, title: brewInfo._brewPropDisplayName}),
e_({tag: "span", clazz: "col-1-4 ve-text-center code", text: timestampModified}),
e_({tag: "span", clazz: "col-1-4 ve-text-center code", text: timestampAddedPublished}),
e_({
tag: "span",
clazz: "col-1 manbrew__source ve-text-center pr-0",
children: [
e_({
tag: "a",
text: `View Raw`,
})
.attr("href", brewInfo.download_url)
.attr("target", "_blank")
.attr("rel", "noopener noreferrer"),
],
}),
],
}),
],
keydown: evt => this._pHandleKeydown_row(evt, {rdState, btnAdd, url: brewInfo.download_url, listItem}),
})
.attr("tabindex", ix);
const listItem = new ListItem(
ix,
eleLi,
brewInfo._brewName,
{
author: brewInfo._brewAuthor,
// category: brewInfo._brewPropDisplayName, // Unwanted in search
internalSources: brewInfo._brewInternalSources, // Used for search
},
{
btnAdd,
cbSel,
pFnDoDownload: ({isLazy = false} = {}) => this._pHandleClick_btnGetRemote({btn: btnAdd, url: brewInfo.download_url, isLazy}),
},
);
eleLi.addEventListener("click", evt => rdState.listSelectClickHandler.handleSelectClick(listItem, evt, {isPassThroughEvents: true}));
return {
listItem,
};
}
async _pHandleKeydown_iptSearch (evt, rdState) {
switch (evt.key) {
case "Enter": {
const firstItem = rdState.list.visibleItems[0];
if (!firstItem) return;
await firstItem.data.pFnDoDownload();
return;
}
case "ArrowDown": {
const firstItem = rdState.list.visibleItems[0];
if (firstItem) {
evt.stopPropagation();
evt.preventDefault();
firstItem.ele.focus();
}
}
}
}
async _pHandleClick_btnAddSelected ({rdState}) {
const listItems = rdState.list.items.filter(it => it.data.cbSel.checked);
if (!listItems.length) return JqueryUtil.doToast({type: "warning", content: `Please select some ${this._brewUtil.DISPLAY_NAME_PLURAL} first!`});
if (listItems.length > 25 && !await InputUiUtil.pGetUserBoolean({title: "Are you sure?", htmlDescription: `<div>You area about to load ${listItems.length} ${this._brewUtil.DISPLAY_NAME} files.<br>Loading large quantities of ${this._brewUtil.DISPLAY_NAME_PLURAL} can lead to performance and stability issues.</div>`, textYes: "Continue"})) return;
rdState.cbAll.checked = false;
rdState.list.items.forEach(item => {
item.data.cbSel.checked = false;
item.ele.classList.remove("list-multi-selected");
});
await Promise.allSettled(listItems.map(it => it.data.pFnDoDownload({isLazy: true})));
const lazyDepsAdded = await this._brewUtil.pAddBrewsLazyFinalize();
this._brewsLoaded.push(...lazyDepsAdded);
JqueryUtil.doToast(`Finished loading selected ${this._brewUtil.DISPLAY_NAME}!`);
}
async _pHandleClick_btnGetRemote ({evt, btn, url, isLazy}) {
if (!(url || "").trim()) return JqueryUtil.doToast({type: "danger", content: `${this._brewUtil.DISPLAY_NAME.uppercaseFirst()} had no download URL!`});
if (evt) {
evt.stopPropagation();
evt.preventDefault();
}
const cachedHtml = btn.html();
btn.txt("Loading...").attr("disabled", true);
const brewsAdded = await this._brewUtil.pAddBrewFromUrl(url, {isLazy});
this._brewsLoaded.push(...brewsAdded);
btn.txt("Done!");
setTimeout(() => btn.html(cachedHtml).attr("disabled", false), VeCt.DUR_INLINE_NOTIFY);
}
async _pHandleKeydown_row (evt, {rdState, btnAdd, url, listItem}) {
switch (evt.key) {
case "Enter": return this._pHandleClick_btnGetRemote({evt, btn: btnAdd, url});
case "ArrowUp": {
const ixCur = rdState.list.visibleItems.indexOf(listItem);
if (~ixCur) {
const prevItem = rdState.list.visibleItems[ixCur - 1];
if (prevItem) {
evt.stopPropagation();
evt.preventDefault();
prevItem.ele.focus();
}
return;
}
const firstItem = rdState.list.visibleItems[0];
if (firstItem) {
evt.stopPropagation();
evt.preventDefault();
firstItem.ele.focus();
}
return;
}
case "ArrowDown": {
const ixCur = rdState.list.visibleItems.indexOf(listItem);
if (~ixCur) {
const nxtItem = rdState.list.visibleItems[ixCur + 1];
if (nxtItem) {
evt.stopPropagation();
evt.preventDefault();
nxtItem.ele.focus();
}
return;
}
const lastItem = rdState.list.visibleItems.last();
if (lastItem) {
evt.stopPropagation();
evt.preventDefault();
lastItem.ele.focus();
}
}
}
}
}
class ManageEditableBrewContentsUi extends BaseComponent {
static _RenderState = class {
constructor () {
this.tabMetaEntities = null;
this.tabMetaSources = null;
this.listEntities = null;
this.listEntitiesSelectClickHandler = null;
this.listSources = null;
this.listSourcesSelectClickHandler = null;
this.contentEntities = null;
this.pageFilterEntities = new ManageEditableBrewContentsUi._PageFilter();
}
};
static _PageFilter = class extends PageFilter {
constructor () {
super();
this._categoryFilter = new Filter({header: "Category"});
}
static mutateForFilters (meta) {
const {ent, prop} = meta;
meta._fSource = SourceUtil.getEntitySource(ent);
meta._fCategory = ManageEditableBrewContentsUi._getDisplayProp({ent, prop});
}
addToFilters (meta) {
this._sourceFilter.addItem(meta._fSource);
this._categoryFilter.addItem(meta._fCategory);
}
async _pPopulateBoxOptions (opts) {
opts.filters = [
this._sourceFilter,
this._categoryFilter,
];
}
toDisplay (values, meta) {
return this._filterBox.toDisplay(
values,
meta._fSource,
meta._fCategory,
);
}
};
static async pDoOpen ({brewUtil, brew, isModal: isParentModal = false}) {
return new Promise((resolve, reject) => {
const ui = new this({brewUtil, brew, isModal: true});
const rdState = new this._RenderState();
const {$modalInner} = UiUtil.getShowModal({
isHeight100: true,
title: `Manage Document Contents`,
isUncappedHeight: true,
isWidth100: true,
$titleSplit: $$`<div class="ve-flex-v-center btn-group">
${ui._$getBtnDeleteSelected({rdState})}
</div>`,
overlayColor: isParentModal ? "transparent" : undefined,
cbClose: () => {
resolve(ui._getFormData());
rdState.pageFilterEntities.filterBox.teardown();
},
});
ui.pRender($modalInner, {rdState})
.catch(e => reject(e));
});
}
constructor ({brewUtil, brew, isModal}) {
super();
TabUiUtil.decorate(this, {isInitMeta: true});
this._brewUtil = brewUtil;
this._brew = MiscUtil.copyFast(brew);
this._isModal = isModal;
this._isDirty = false;
}
_getFormData () {
return {
isDirty: this._isDirty,
brew: this._brew,
};
}
_$getBtnDeleteSelected ({rdState}) {
return $(`<button class="btn btn-danger btn-xs">Delete Selected</button>`)
.click(() => this._handleClick_pButtonDeleteSelected({rdState}));
}
async _handleClick_pButtonDeleteSelected ({rdState}) {
if (this._getActiveTab() === rdState.tabMetaEntities) return this._handleClick_pButtonDeleteSelected_entities({rdState});
if (this._getActiveTab() === rdState.tabMetaSources) return this._handleClick_pButtonDeleteSelected_sources({rdState});
// (The metadata tab does not have any selectable elements, so, no-op)
}
async _handleClick_pButtonDeleteSelected_entities ({rdState}) {
const listItemsSel = rdState.listEntities.items
.filter(it => it.data.cbSel.checked);
if (!listItemsSel.length) return;
if (!await InputUiUtil.pGetUserBoolean({title: "Delete Entities", htmlDescription: `Are you sure you want to delete the ${listItemsSel.length === 1 ? "selected entity" : `${listItemsSel.length} selected entities`}?`, textYes: "Yes", textNo: "Cancel"})) return;
this._isDirty = true;
// Remove the array items from our copy of the brew, and remove the corresponding list items
listItemsSel
.forEach(li => this._doEntityListDelete({rdState, li}));
rdState.listEntities.update();
}
_doEntityListDelete ({rdState, li}) {
const ix = this._brew.body[li.data.prop].indexOf(li.data.ent);
if (!~ix) return;
this._brew.body[li.data.prop].splice(ix, 1);
if (!this._brew.body[li.data.prop].length) delete this._brew.body[li.data.prop];
rdState.listEntities.removeItem(li);
}
async _handleClick_pButtonDeleteSelected_sources ({rdState}) {
const listItemsSel = rdState.listSources.items
.filter(it => it.data.cbSel.checked);
if (!listItemsSel.length) return;
if (
!await InputUiUtil.pGetUserBoolean({
title: "Delete Sources",
htmlDescription: `<div>Are you sure you want to delete the ${listItemsSel.length === 1 ? "selected source" : `${listItemsSel.length} selected sources`}?<br><b>This will delete all entities with ${listItemsSel.length === 1 ? "that source" : `these sources`}</b>.</div>`,
textYes: "Yes",
textNo: "Cancel",
})
) return;
this._isDirty = true;
// Remove the sources from our copy of the brew, and remove the corresponding list items
listItemsSel
.forEach(li => {
const ix = this._brew.body._meta.sources.indexOf(li.data.source);
if (!~ix) return;
this._brew.body._meta.sources.splice(ix, 1);
rdState.listSources.removeItem(li);
});
rdState.listSources.update();
// Remove all entities with matching sources, and remove the corresponding list items
const sourceSetRemoved = new Set(listItemsSel.map(li => li.data.source.json));
rdState.listEntities.visibleItems
.forEach(li => {
const source = SourceUtil.getEntitySource(li.data.ent);
if (!sourceSetRemoved.has(source)) return;
this._doEntityListDelete({rdState, li});
});
rdState.listEntities.update();
}
async pRender ($wrp, {rdState = null} = {}) {
rdState = rdState || new this.constructor._RenderState();
const iptTabMetas = [
new TabUiUtil.TabMeta({name: "Entities", hasBorder: true}),
new TabUiUtil.TabMeta({name: "Metadata", hasBorder: true}),
new TabUiUtil.TabMeta({name: "Sources", hasBorder: true}),
];
const tabMetas = this._renderTabs(iptTabMetas, {$parent: $wrp});
const [tabMetaEntities, tabMetaMetadata, tabMetaSources] = tabMetas;
rdState.tabMetaEntities = tabMetaEntities;
rdState.tabMetaSources = tabMetaSources;
this._pRender_tabEntities({tabMeta: tabMetaEntities, rdState});
this._pRender_tabMetadata({tabMeta: tabMetaMetadata, rdState});
this._pRender_tabSources({tabMeta: tabMetaSources, rdState});
}
_pRender_tabEntities ({tabMeta, rdState}) {
const $btnFilter = $(`<button class="btn btn-default">Filter</button>`);
const $btnToggleSummaryHidden = $(`<button class="btn btn-default" title="Toggle Filter Summary Display"><span class="glyphicon glyphicon-resize-small"></span></button>`);
const $btnReset = $(`<button class="btn btn-default">Reset</button>`);
const $wrpMiniPills = $(`<div class="fltr__mini-view btn-group"></div>`);
const $cbAll = $(`<input type="checkbox">`);
const $wrpRows = $$`<div class="list ve-flex-col w-100 max-h-unset"></div>`;
const $iptSearch = $(`<input type="search" class="search manbrew__search form-control w-100 lst__search lst__search--no-border-h" placeholder="Search entries...">`);
const $dispCntVisible = $(`<div class="lst__wrp-search-visible no-events ve-flex-vh-center"></div>`);
const $wrpBtnsSort = $$`<div class="filtertools manbrew__filtertools input-group input-group--bottom ve-flex no-shrink">
<label class="btn btn-default btn-xs col-1 pr-0 ve-flex-vh-center">${$cbAll}</label>
<button class="col-5 sort btn btn-default btn-xs" data-sort="name">Name</button>
<button class="col-1 sort btn btn-default btn-xs" data-sort="source">Source</button>
<button class="col-5 sort btn btn-default btn-xs" data-sort="category">Category</button>
</div>`;
$$(tabMeta.$wrpTab)`
<div class="ve-flex-v-stretch input-group input-group--top no-shrink mt-1">
${$btnFilter}
${$btnToggleSummaryHidden}
<div class="w-100 relative">
${$iptSearch}
<div id="lst__search-glass" class="lst__wrp-search-glass no-events ve-flex-vh-center"><span class="glyphicon glyphicon-search"></span></div>
${$dispCntVisible}
</div>
${$btnReset}
</div>
${$wrpMiniPills}
${$wrpBtnsSort}
${$wrpRows}`;
rdState.listEntities = new List({
$iptSearch,
$wrpList: $wrpRows,
fnSort: SortUtil.listSort,
});
rdState.listEntities.on("updated", () => $dispCntVisible.html(`${rdState.listEntities.visibleItems.length}/${rdState.listEntities.items.length}`));
rdState.listEntitiesSelectClickHandler = new ListSelectClickHandler({list: rdState.listEntities});
rdState.listEntitiesSelectClickHandler.bindSelectAllCheckbox($cbAll);
SortUtil.initBtnSortHandlers($wrpBtnsSort, rdState.listEntities);
let ixParent = 0;
rdState.contentEntities = Object.entries(this._brew.body)
.filter(([, v]) => v instanceof Array && v.length)
.map(([prop, arr]) => arr.map(ent => ({ent, prop, ixParent: ixParent++})))
.flat();
rdState.contentEntities.forEach(({ent, prop, ixParent}) => {
const {listItem} = this._pRender_getEntityRowMeta({rdState, prop, ent, ixParent});
rdState.listEntities.addItem(listItem);
});
rdState.pageFilterEntities.pInitFilterBox({
$iptSearch: $iptSearch,
$btnReset: $btnReset,
$btnOpen: $btnFilter,
$btnToggleSummaryHidden: $btnToggleSummaryHidden,
$wrpMiniPills: $wrpMiniPills,
namespace: `${this.constructor.name}__tabEntities`,
}).then(async () => {
rdState.contentEntities.forEach(meta => rdState.pageFilterEntities.mutateAndAddToFilters(meta));
rdState.listEntities.init();
rdState.pageFilterEntities.trimState();
rdState.pageFilterEntities.filterBox.render();
rdState.pageFilterEntities.filterBox.on(
FilterBox.EVNT_VALCHANGE,
this._handleFilterChange_entities.bind(this, {rdState}),
);
this._handleFilterChange_entities({rdState});
$iptSearch.focus();
});
}
_handleFilterChange_entities ({rdState}) {
const f = rdState.pageFilterEntities.filterBox.getValues();
rdState.listEntities.filter(li => rdState.pageFilterEntities.toDisplay(f, rdState.contentEntities[li.ix]));
}
_pRender_getEntityRowMeta ({rdState, prop, ent, ixParent}) {
const eleLi = document.createElement("div");
eleLi.className = "lst__row ve-flex-col px-0";
const dispName = this.constructor._getDisplayName({brew: this._brew, ent, prop});
const sourceMeta = this.constructor._getSourceMeta({brew: this._brew, ent});
const dispProp = this.constructor._getDisplayProp({ent, prop});
eleLi.innerHTML = `<label class="lst--border lst__row-inner no-select mb-0 ve-flex-v-center">
<div class="pl-0 col-1 ve-flex-vh-center"><input type="checkbox" class="no-events"></div>
<div class="col-5 bold">${dispName}</div>
<div class="col-1 ve-text-center" title="${(sourceMeta.full || "").qq()}" ${this._brewUtil.sourceToStyle(sourceMeta)}>${sourceMeta.abbreviation}</div>
<div class="col-5 ve-flex-vh-center pr-0">${dispProp}</div>
</label>`;
const listItem = new ListItem(
ixParent, // We identify the item in the list according to its position across all props
eleLi,
dispName,
{
source: sourceMeta.abbreviation,
category: dispProp,
},
{
cbSel: eleLi.firstElementChild.firstElementChild.firstElementChild,
prop,
ent,
},
);
eleLi.addEventListener("click", evt => rdState.listEntitiesSelectClickHandler.handleSelectClick(listItem, evt));
return {
listItem,
};
}
_pRender_tabMetadata ({tabMeta, rdState}) {
const infoTuples = Object.entries(this.constructor._PROP_INFOS_META).filter(([k]) => Object.keys(this._brew.body?._meta?.[k] || {}).length);
if (!infoTuples.length) {
$$(tabMeta.$wrpTab)`
<h4>Metadata</h4>
<p><i>No metadata found.</i></p>
`;
return;
}
const metasSections = infoTuples
.map(([prop, info]) => this._pRender_getMetaRowMeta({prop, info}));
$$(tabMeta.$wrpTab)`
<div class="pt-2"><i>Warning: deleting metadata may invalidate or otherwise corrupt homebrew which depends on it. Use with caution.</i></div>
<hr class="hr-3">
${metasSections.map(({$wrp}) => $wrp)}
`;
}
_pRender_getMetaRowMeta ({prop, info}) {
const displayName = info.displayName || prop.toTitleCase();
const displayFn = info.displayFn || ((...args) => args.last().toTitleCase());
const $rows = Object.keys(this._brew.body._meta[prop])
.map(k => {
const $btnDelete = $(`<button class="btn btn-danger btn-xs" title="Delete"><span class="glyphicon glyphicon-trash"></span></button>`)
.click(() => {
this._isDirty = true;
MiscUtil.deleteObjectPath(this._brew.body._meta, prop, k);
$row.remove();
// If we deleted the last key and the whole prop has therefore been cleaned up, delete the section
if (this._brew.body._meta[prop]) return;
$wrp.remove();
});
const $row = $$`<div class="lst__row ve-flex-col px-0">
<div class="split-v-center lst--border lst__row-inner no-select mb-0 ve-flex-v-center">
<div class="col-10">${displayFn(this._brew, prop, k)}</div>
<div class="col-2 btn-group ve-flex-v-center ve-flex-h-right">
${$btnDelete}
</div>
</div>
</div>`;
return $row;
});
const $wrp = $$`<div class="ve-flex-col mb-4">
<div class="bold mb-2">${displayName}:</div>
<div class="ve-flex-col list-display-only">${$rows}</div>
</div>`;
return {
$wrp,
};
}
_pRender_tabSources ({tabMeta, rdState}) {
const $cbAll = $(`<input type="checkbox">`);
const $wrpRows = $$`<div class="list ve-flex-col w-100 max-h-unset"></div>`;
const $iptSearch = $(`<input type="search" class="search manbrew__search form-control w-100 mt-1" placeholder="Search source...">`);
const $wrpBtnsSort = $$`<div class="filtertools manbrew__filtertools input-group input-group--bottom ve-flex no-shrink">
<label class="btn btn-default btn-xs col-1 pr-0 ve-flex-vh-center">${$cbAll}</label>
<button class="col-5 sort btn btn-default btn-xs" data-sort="name">Name</button>
<button class="col-2 sort btn btn-default btn-xs" data-sort="abbreviation">Abbreviation</button>
<button class="col-4 sort btn btn-default btn-xs" data-sort="json">JSON</button>
</div>`;
$$(tabMeta.$wrpTab)`
${$iptSearch}
${$wrpBtnsSort}
${$wrpRows}`;
rdState.listSources = new List({
$iptSearch,
$wrpList: $wrpRows,
fnSort: SortUtil.listSort,
});
rdState.listSourcesSelectClickHandler = new ListSelectClickHandler({list: rdState.listSources});
rdState.listSourcesSelectClickHandler.bindSelectAllCheckbox($cbAll);
SortUtil.initBtnSortHandlers($wrpBtnsSort, rdState.listSources);
(this._brew.body?._meta?.sources || [])
.forEach((source, ix) => {
const {listItem} = this._pRender_getSourceRowMeta({rdState, source, ix});
rdState.listSources.addItem(listItem);
});
rdState.listSources.init();
$iptSearch.focus();
}
_pRender_getSourceRowMeta ({rdState, source, ix}) {
const eleLi = document.createElement("div");
eleLi.className = "lst__row ve-flex-col px-0";
const name = source.full || _BrewInternalUtil.SOURCE_UNKNOWN_FULL;
const abv = source.abbreviation || _BrewInternalUtil.SOURCE_UNKNOWN_ABBREVIATION;
eleLi.innerHTML = `<label class="lst--border lst__row-inner no-select mb-0 ve-flex-v-center">
<div class="pl-0 col-1 ve-flex-vh-center"><input type="checkbox" class="no-events"></div>
<div class="col-5 bold">${name}</div>
<div class="col-2 ve-text-center">${abv}</div>
<div class="col-4 ve-flex-vh-center pr-0">${source.json}</div>
</label>`;
const listItem = new ListItem(
ix,
eleLi,
name,
{
abbreviation: abv,
json: source.json,
},
{
cbSel: eleLi.firstElementChild.firstElementChild.firstElementChild,
source,
},
);
eleLi.addEventListener("click", evt => rdState.listSourcesSelectClickHandler.handleSelectClick(listItem, evt));
return {
listItem,
};
}
static _NAME_UNKNOWN = "(Unknown)";
static _getDisplayName ({brew, ent, prop}) {
switch (prop) {
case "itemProperty": {
if (ent.name) return ent.name || this._NAME_UNKNOWN;
if (ent.entries) {
const name = Renderer.findName(ent.entries);
if (name) return name;
}
if (ent.entriesTemplate) {
const name = Renderer.findName(ent.entriesTemplate);
if (name) return name;
}
return ent.abbreviation || this._NAME_UNKNOWN;
}
case "adventureData":
case "bookData": {
const propContents = prop === "adventureData" ? "adventure" : "book";
if (!brew[propContents]) return ent.id || this._NAME_UNKNOWN;
return brew[propContents].find(it => it.id === ent.id)?.name || ent.id || this._NAME_UNKNOWN;
}
default: return ent.name || this._NAME_UNKNOWN;
}
}
static _getSourceMeta ({brew, ent}) {
const entSource = SourceUtil.getEntitySource(ent);
if (!entSource) return {abbreviation: _BrewInternalUtil.SOURCE_UNKNOWN_ABBREVIATION, full: _BrewInternalUtil.SOURCE_UNKNOWN_FULL};
const source = (brew.body?._meta?.sources || []).find(src => src.json === entSource);
if (!source) return {abbreviation: _BrewInternalUtil.SOURCE_UNKNOWN_ABBREVIATION, full: _BrewInternalUtil.SOURCE_UNKNOWN_FULL};
return source;
}
static _getDisplayProp ({ent, prop}) {
const out = [Parser.getPropDisplayName(prop)];
switch (prop) {
case "subclass": out.push(` (${ent.className})`); break;
case "subrace": out.push(` (${ent.raceName})`); break;
case "psionic": out.push(` (${Parser.psiTypeToMeta(ent.type).short})`); break;
}
return out.filter(Boolean).join(" ");
}
/** These are props found in "_meta" sections of files */
static _PROP_INFOS_META = {
"spellDistanceUnits": {
displayName: "Spell Distance Units",
},
"spellSchools": {
displayName: "Spell Schools",
displayFn: (brew, propMeta, k) => brew.body._meta[propMeta][k].full || k,
},
"currencyConversions": {
displayName: "Currency Conversion Tables",
displayFn: (brew, propMeta, k) => `${k}: ${brew.body._meta[propMeta][k].map(it => `${it.coin}=${it.mult}`).join(", ")}`,
},
"skills": {
displayName: "Skills",
},
"senses": {
displayName: "Senses",
},
"optionalFeatureTypes": {
displayName: "Optional Feature Types",
displayFn: (brew, propMeta, k) => brew.body._meta[propMeta][k] || k,
},
"charOption": {
displayName: "Character Creation Option Types",
displayFn: (brew, propMeta, k) => brew.body._meta[propMeta][k] || k,
},
"psionicTypes": {
displayName: "Psionic Types",
displayFn: (brew, propMeta, k) => brew.body._meta[propMeta][k].full || k,
},
};
}