This commit is contained in:
TheGiddyLimit
2024-07-10 20:47:40 +01:00
parent e5844f8a3f
commit 2eeeb0771b
341 changed files with 67623 additions and 11384 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
export const SOURCE_UNKNOWN_FULL = "(Unknown)";
export const SOURCE_UNKNOWN_ABBREVIATION = "(UNK)";

View File

@@ -0,0 +1,7 @@
export 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);
}
}

View File

@@ -0,0 +1,263 @@
import {BrewUtil2Base} from "./utils-brew-base.js";
import {BrewDoc} from "./utils-brew-models.js";
export 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()) this.doLocationReload();
});
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;
}
async pHasEditableSourceJson () {
const brewsStored = await this._pGetBrewRaw();
if (!brewsStored?.length) return false;
return brewsStored
.map(brew => BrewDoc.fromObject(brew))
.some(brew => brew.head.isEditable && !brew.isEmpty());
}
// endregion
}

View File

@@ -0,0 +1,63 @@
import {BrewUtil2Base} from "./utils-brew-base.js";
export 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}); }
async pHasEditableSourceJson () { return false; }
// endregion
}

View File

@@ -0,0 +1,261 @@
export class BrewDoc {
// Things which are stored in "_meta", but are "content metadata" rather than "file metadata."
static _META_KEYS_CONTENT_METADATA__OBJECT = [
"skills",
"senses",
"spellSchools",
"spellDistanceUnits",
"optionalFeatureTypes",
"psionicTypes",
"currencyConversions",
];
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;
}
isEmpty () {
if (
Object.entries(this.body)
.some(([k, v]) => {
if (!(v instanceof Array)) return false;
if (k === "_meta" || k === "_test") return false;
return !!v.length;
})
) return false;
if (!this.body._meta) return false;
if (
this.constructor._META_KEYS_CONTENT_METADATA__OBJECT
.some(k => !!Object.keys(this.body._meta[k] || {}).length)
) return false;
return true;
}
// 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});
case "_test": return; // ignore; used for static testing
default: return this._mergeObjects_default({out, prop, val});
}
});
});
return out;
}
static _META_KEYS_MERGEABLE_OBJECTS = [
...this._META_KEYS_CONTENT_METADATA__OBJECT,
];
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;
}
}

View File

@@ -0,0 +1,488 @@
import {EVNT_VALCHANGE} from "../filter/filter-constants.js";
export 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 PageFilterBase {
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 () {
this._dataList = await this._brewUtil.pGetCombinedIndexes();
}
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 ve-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="ve-col-1-4 sort btn btn-default btn-xs" data-sort="added">Added</button>`
: `<button class="ve-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="ve-col-0-5 pr-0 btn btn-default btn-xs ve-flex-vh-center">${rdState.cbAll}</label>
<button class="ve-col-3-5 sort btn btn-default btn-xs" data-sort="name">Name</button>
<button class="ve-col-3 sort btn btn-default btn-xs" data-sort="author">Author</button>
<button class="ve-col-1-2 sort btn btn-default btn-xs" data-sort="category">Category</button>
<button class="ve-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(
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: `ve-col-3-5 bold manbrew__load_from_url pl-0 clickable`,
text: brewInfo._brewName,
click: evt => this._pHandleClick_btnGetRemote({evt, btn: btnAdd, url: brewInfo.urlDownload}),
});
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: `ve-col-0-5 ve-flex-vh-center ve-self-flex-stretch`,
children: [cbSel],
}),
btnAdd,
e_({tag: "span", clazz: "ve-col-3", text: brewInfo._brewAuthor}),
e_({tag: "span", clazz: "ve-col-1-2 ve-text-center mobile__text-clip-ellipsis", text: brewInfo._brewPropDisplayName, title: brewInfo._brewPropDisplayName}),
e_({tag: "span", clazz: "ve-col-1-4 ve-text-center code", text: timestampModified}),
e_({tag: "span", clazz: "ve-col-1-4 ve-text-center code", text: timestampAddedPublished}),
e_({
tag: "span",
clazz: "ve-col-1 manbrew__source ve-text-center pr-0",
children: [
e_({
tag: "a",
text: `View Raw`,
})
.attr("href", brewInfo.urlDownload)
.attr("target", "_blank")
.attr("rel", "noopener noreferrer"),
],
}),
],
}),
],
keydown: evt => this._pHandleKeydown_row(evt, {rdState, btnAdd, url: brewInfo.urlDownload, 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.urlDownload, 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();
}
}
}
}
}

View File

@@ -0,0 +1,531 @@
import {SOURCE_UNKNOWN_ABBREVIATION, SOURCE_UNKNOWN_FULL} from "./utils-brew-constants.js";
import {EVNT_VALCHANGE} from "../filter/filter-constants.js";
export 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 PageFilterBase {
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 ve-col-1 pr-0 ve-flex-vh-center">${$cbAll}</label>
<button class="ve-col-5 sort btn btn-default btn-xs" data-sort="name">Name</button>
<button class="ve-col-1 sort btn btn-default btn-xs" data-sort="source">Source</button>
<button class="ve-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(
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 ve-col-1 ve-flex-vh-center"><input type="checkbox" class="no-events"></div>
<div class="ve-col-5 bold">${dispName}</div>
<div class="ve-col-1 ve-text-center" title="${(sourceMeta.full || "").qq()}" ${this._brewUtil.sourceToStyle(sourceMeta)}>${sourceMeta.abbreviation}</div>
<div class="ve-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="ve-col-10">${displayFn(this._brew, prop, k)}</div>
<div class="ve-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 ve-col-1 pr-0 ve-flex-vh-center">${$cbAll}</label>
<button class="ve-col-5 sort btn btn-default btn-xs" data-sort="name">Name</button>
<button class="ve-col-2 sort btn btn-default btn-xs" data-sort="abbreviation">Abbreviation</button>
<button class="ve-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 || SOURCE_UNKNOWN_FULL;
const abv = source.abbreviation || SOURCE_UNKNOWN_ABBREVIATION;
eleLi.innerHTML = `<label class="lst--border lst__row-inner no-select mb-0 ve-flex-v-center">
<div class="pl-0 ve-col-1 ve-flex-vh-center"><input type="checkbox" class="no-events"></div>
<div class="ve-col-5 bold">${name}</div>
<div class="ve-col-2 ve-text-center">${abv}</div>
<div class="ve-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: SOURCE_UNKNOWN_ABBREVIATION, full: SOURCE_UNKNOWN_FULL};
const source = (brew.body?._meta?.sources || []).find(src => src.json === entSource);
if (!source) return {abbreviation: SOURCE_UNKNOWN_ABBREVIATION, full: 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,
},
};
}

View File

@@ -0,0 +1,981 @@
import {SOURCE_UNKNOWN_ABBREVIATION, SOURCE_UNKNOWN_FULL} from "./utils-brew-constants.js";
import {BrewDoc} from "./utils-brew-models.js";
import {GetBrewUi} from "./utils-brew-ui-get.js";
import {ManageEditableBrewContentsUi} from "./utils-brew-ui-manage-editable-contents.js";
import {ManageExternalUtils} from "../manageexternal/manageexternal-utils.js";
export 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 _CONTEXT_MENU_BTNGROUP_MANAGER = null;
static bindBtngroupManager (btngroup) {
btngroup
.first(`[name="manage-content"]`)
.onn("click", evt => this._pOnClickBtnManageContent({evt}));
btngroup
.first(`[name="manage-prerelease"]`)
.onn("click", evt => this._onClickBtnManagePrereleaseBrew({brewUtil: PrereleaseUtil, isGoToPage: evt.shiftKey}));
btngroup
.first(`[name="manage-brew"]`)
.onn("click", evt => this._onClickBtnManagePrereleaseBrew({brewUtil: BrewUtil2, isGoToPage: evt.shiftKey}));
}
static bindBtnOpen ($btn, {brewUtil = null} = {}) {
brewUtil = brewUtil || BrewUtil2;
$btn.click(evt => this._onClickBtnManagePrereleaseBrew({brewUtil, isGoToPage: evt.shiftKey}));
}
static _pOnClickBtnManageContent ({evt}) {
this._CONTEXT_MENU_BTNGROUP_MANAGER ||= ContextUtil.getMenu([
new ContextUtil.Action(
"Manage Prerelease Content",
async evt => {
this._onClickBtnManagePrereleaseBrew({brewUtil: PrereleaseUtil, isGoToPage: evt.shiftKey});
},
),
new ContextUtil.Action(
"Manage Homebrew",
async evt => {
this._onClickBtnManagePrereleaseBrew({brewUtil: BrewUtil2, isGoToPage: evt.shiftKey});
},
),
null,
new ContextUtil.Action(
"Load All Partnered Content",
async evt => {
await this.pOnClickBtnLoadAllPartnered();
},
),
null,
new ContextUtil.Action(
"Delete All Loaded Content",
async evt => {
await this._pOnClickBtnDeleteAllLoadedContent();
},
),
]);
return ContextUtil.pOpenMenu(evt, this._CONTEXT_MENU_BTNGROUP_MANAGER);
}
static _onClickBtnManagePrereleaseBrew ({brewUtil, isGoToPage}) {
if (isGoToPage) return window.location = brewUtil.PAGE_MANAGE;
return this.pDoManageBrew({brewUtil});
}
static async pOnClickBtnLoadAllPartnered () {
const brewDocs = [];
try {
const [brewDocsPrerelease, brewDocsHomebrew] = await Promise.all([
PrereleaseUtil.pAddBrewsPartnered({isSilent: true}),
BrewUtil2.pAddBrewsPartnered({isSilent: true}),
]);
brewDocs.push(
...brewDocsPrerelease,
...brewDocsHomebrew,
);
} catch (e) {
JqueryUtil.doToast({type: "danger", content: `Failed to load partnered content! ${VeCt.STR_SEE_CONSOLE}`});
throw e;
}
if (brewDocs.length) JqueryUtil.doToast(`Loaded partnered content!`);
if (PrereleaseUtil.isReloadRequired()) PrereleaseUtil.doLocationReload();
if (BrewUtil2.isReloadRequired()) BrewUtil2.doLocationReload();
}
static async _pOnClickBtnDeleteAllLoadedContent () {
if (
!await InputUiUtil.pGetUserBoolean({
title: `Delete All Loaded ${PrereleaseUtil.DISPLAY_NAME.toTitleCase()} and ${BrewUtil2.DISPLAY_NAME.toTitleCase()}`,
htmlDescription: `<div>
<div>Are you sure?</div>
<div class="ve-muted"><i>Note that this will <b>not</b> delete your Editable ${PrereleaseUtil.DISPLAY_NAME.toTitleCase()} and Editable ${BrewUtil2.DISPLAY_NAME.toTitleCase()}.</i></div>
</div>`,
textYes: "Yes",
textNo: "Cancel",
})
) return;
try {
await Promise.all([
PrereleaseUtil.pDeleteUneditableBrews(),
BrewUtil2.pDeleteUneditableBrews(),
]);
} catch (e) {
JqueryUtil.doToast({type: "danger", content: `Failed to load partnered content! ${VeCt.STR_SEE_CONSOLE}`});
throw e;
}
if (PrereleaseUtil.isReloadRequired()) PrereleaseUtil.doLocationReload();
if (BrewUtil2.isReloadRequired()) BrewUtil2.doLocationReload();
}
/* -------------------------------------------- */
static async pOnClickBtnExportListAsUrl ({ele}) {
const url = await ManageExternalUtils.pGetUrl();
await MiscUtil.pCopyTextToClipboard(url);
JqueryUtil.showCopiedEffect(ele);
if (
!await PrereleaseUtil.pHasEditableSourceJson()
&& !await BrewUtil2.pHasEditableSourceJson()
) return;
JqueryUtil.doToast({type: "warning", content: `Note: you have Editable ${PrereleaseUtil.DISPLAY_NAME} or ${BrewUtil2.DISPLAY_NAME}. This cannot be exported as part of a URL, and so was not included.`});
}
/* -------------------------------------------- */
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;
brewUtil.doLocationReload();
},
});
await ui.pRender($modalInner, {rdState});
}
_$getBtnDeleteAll (rdState) {
const brewUtilOther = this._brewUtil === PrereleaseUtil ? BrewUtil2 : PrereleaseUtil;
return $(`<button class="btn btn-danger" title="SHIFT to also delete all ${brewUtilOther.DISPLAY_NAME.toTitleCase()}">Delete All</button>`)
.addClass(this._isModal ? "btn-xs" : "btn-sm")
.click(async evt => {
if (!evt.shiftKey) {
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);
return;
}
if (
!await InputUiUtil.pGetUserBoolean({
title: `Delete All ${this._brewUtil.DISPLAY_NAME.toTitleCase()} and ${brewUtilOther.DISPLAY_NAME.toTitleCase()}`,
htmlDescription: "Are you sure?",
textYes: "Yes",
textNo: "Cancel",
})
) return;
await brewUtilOther.pSetBrew([]);
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 btnLoadPartnered = ee`<button class="btn btn-default btn-sm">Load All Partnered</button>`
.onn("click", () => this._pHandleClick_btnLoadPartnered(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 evt => {
await this.constructor.pOnClickBtnExportListAsUrl({ele: evt.originalEvent.currentTarget});
});
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">
${btnLoadPartnered}
</div>
<div class="ve-flex-v-center btn-group mr-2">
${$btnLoadFromFile}
${$btnLoadFromUrl}
</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 mr-2">
${$btnSaveToUrl}
</div>
<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_btnLoadPartnered (rdState) {
await this._brewUtil.pAddBrewsPartnered();
await this._pRender_pBrewList(rdState);
}
async _pHandleClick_btnLoadFromFile (rdState) {
const {files, errors} = await InputUiUtil.pGetUserUploadJson({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 ve-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="ve-col-0-5 pr-0 btn btn-default btn-xs ve-flex-vh-center">${$cbAll}</label>
<button class="ve-col-1 btn btn-default btn-xs" disabled>Type</button>
<button class="ve-col-3 btn btn-default btn-xs" data-sort="source">Source</button>
<button class="ve-col-3 btn btn-default btn-xs" data-sort="authors">Authors</button>
<button class="ve-col-3 btn btn-default btn-xs" disabled>Origin</button>
<button class="ve-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 || 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: "ve-col-2 ve-text-center",
href: brewSource.url,
attrs: {
target: "_blank",
rel: "noopener noreferrer",
},
text: "View Source",
})
: e_({
tag: "span",
clazz: "ve-col-2 ve-text-center",
});
const eleRow = e_({
tag: "div",
clazz: `w-100 ve-flex-v-center`,
children: [
e_({
tag: "span",
clazz: `ve-col-4 manbrew__source px-1`,
text: brewSource.full,
}),
e_({
tag: "span",
clazz: `ve-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 || SOURCE_UNKNOWN_FULL,
abbreviation: brewSource.abbreviation || 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-lg__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: `ve-col-0-5 ve-flex-vh-center ve-self-flex-stretch`,
children: [cbSel],
}),
e_({
tag: "div",
clazz: `ve-col-1 ve-text-center italic mobile__text-clip-ellipsis`,
title: ptCategory.title,
text: ptCategory.short,
}),
e_({
tag: "div",
clazz: `ve-col-9 ve-flex-col`,
children: elesSub,
}),
e_({
tag: "div",
clazz: `ve-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;
}
}