/**
* @file
* @brief List/group NAME logic: the SFW/NSFW variant suffixes, the reserved `keyword` wildcard,
* the guaranteed natural-order comparator, the physical→logical reference set, suffix-path
* resolution, and the shortest-unambiguous display tokens. Pure, browser-safe.
*/
/** Suffix tokens that select an exclusive SFW or NSFW-inclusive variant. */
export const SFW_SUFFIX = /-sfw$/i;
export const NSFW_SUFFIX = /-nsfw$/i;
/**
* Reserved wildcard base. `{keyword}` (and `{keyword-sfw}` / `{keyword-nsfw}`) are
* not files — they resolve to a random word drawn from ALL loaded vocabulary
* (mode-aware). The name is reserved: it always supersedes any list literally named
* `keyword`, silently (no error), the same way `nsfw` is a reserved filename token.
* @type {string}
*/
export const RESERVED_WILDCARD = "keyword";
/**
* @param {string} name A reference (may carry a `-sfw`/`-nsfw` suffix).
* @returns {boolean} Whether it is the reserved `keyword` wildcard (any variant).
*/
export function isReservedWildcard(name) {
return String(name).replace(/-(sfw|nsfw)$/i, "") === RESERVED_WILDCARD;
}
/**
* Class rank for a single character: symbols (0) sort before digits (1) before
* letters (2). Gives a guaranteed, predictable ordering.
* @param {string} ch A single character.
* @returns {number} 0 symbol, 1 digit, 2 letter.
*/
function charRank(ch) {
if (ch >= "0" && ch <= "9") return 1;
const l = ch.toLowerCase();
if (l >= "a" && l <= "z") return 2;
return 0;
}
/** @param {string} ch A single character. @returns {boolean} Whether it is an ASCII digit. */
function isDigit(ch) {
return ch >= "0" && ch <= "9";
}
/** @returns {number} The index just past the run of digits in `s` starting at `start`. */
function scanDigits(s, start) {
let k = start;
while (k < s.length && isDigit(s[k])) k++;
return k;
}
/**
* Natural-order comparator giving a GUARANTEED load/precedence order: symbols
* first, then numbers in true numeric order (so `2` before `10`), then letters
* alphabetically. Lets users engineer a deterministic default by prefixing a
* name with a symbol or number.
* @param {string} a First name.
* @param {string} b Second name.
* @returns {number} Negative, zero, or positive.
*/
export function compareNames(a, b) {
a = String(a);
b = String(b);
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
const ca = a[i];
const cb = b[j];
if (isDigit(ca) && isDigit(cb)) {
const ni = scanDigits(a, i);
const nj = scanDigits(b, j);
const na = Number.parseInt(a.slice(i, ni), 10);
const nb = Number.parseInt(b.slice(j, nj), 10);
if (na !== nb) return na - nb;
i = ni;
j = nj;
continue;
}
const ra = charRank(ca);
const rb = charRank(cb);
if (ra !== rb) return ra - rb;
if (ca !== cb) return ca < cb ? -1 : 1;
i++;
j++;
}
return a.length - i - (b.length - j);
}
/**
* @param {string[]} names The on-disk list + group names (no extension).
* @returns {string[]} De-duplicated, in the guaranteed natural order (compareNames).
*/
export function allListNames(names) {
return Array.from(new Set(names)).sort(compareNames);
}
/**
* @param {string} name A list/group name.
* @returns {boolean} Whether it ends in an explicit `-sfw`/`-nsfw` variant suffix.
*/
export function hasVariantSuffix(name) {
return SFW_SUFFIX.test(name) || NSFW_SUFFIX.test(name);
}
/**
* Turn the physical on-disk names into the LOGICAL reference set, the names the rest
* of the app sees. A mixed list is stored as two files, `<base>-sfw` and `<base>-nsfw`,
* with NO `<base>` file — the bare `{base}` is implicit, and this exposes all three
* references (`base`, `base-sfw`, `base-nsfw`). A standalone `<base>-nsfw` with no
* `<base>-sfw` counterpart is exposed only by its gated `-nsfw` name (NSFW-only).
*
* **Safety rule:** a plain `<p>` file is only a normal SFW list when it has NO
* `<p>-nsfw` sibling; if such a sibling exists the plain file is IGNORED (not exposed,
* not loaded), to force the explicit `-sfw`/`-nsfw` split. Only `<base>-sfw` counts as
* an SFW source — a stray `<base>` next to `<base>-nsfw` does not.
* @param {string[]} physical The on-disk list + group names (no extension).
* @returns {string[]} Logical names, de-duplicated, in guaranteed natural order.
*/
export function logicalListNames(physical) {
const P = new Set(physical);
const out = new Set();
for (const p of physical) {
if (SFW_SUFFIX.test(p)) {
const base = p.replace(SFW_SUFFIX, "");
out.add(base);
out.add(`${base}-sfw`);
if (P.has(`${base}-nsfw`)) out.add(`${base}-nsfw`);
} else if (NSFW_SUFFIX.test(p)) {
const base = p.replace(NSFW_SUFFIX, "");
out.add(p);
if (P.has(`${base}-sfw`)) {
// genuine mixed pair -> also expose the implicit base + explicit SFW ref
out.add(base);
out.add(`${base}-sfw`);
}
} else if (!P.has(`${p}-nsfw`)) {
// plain SFW list; ignored entirely if a `${p}-nsfw` sibling exists (safety rule)
out.add(p);
}
}
return Array.from(out).sort(compareNames);
}
/**
* Resolve a list reference to a canonical list name by PATH-SUFFIX matching, so a
* prompt can use a bare filename (`general`), a partial path (`danbooru/general`),
* or a full path — and folders can be nested arbitrarily deep. An exact match wins;
* otherwise any name whose path ends with `/<ref>` matches, and the shallowest
* (fewest folders), then alphabetically-first, match is chosen for determinism.
* @param {string} ref The reference as written in the prompt.
* @param {string[]} names All known canonical names (physical paths + virtual names).
* @returns {string} The resolved canonical name (or `ref` unchanged if nothing matches).
*/
export function resolveName(ref, names) {
if (!ref) return ref;
// Reserved `keyword` wildcard (any variant) supersedes everything — never resolve
// it to a file path (e.g. it must NOT match danbooru/d/keyword by suffix).
if (isReservedWildcard(ref)) return ref;
if (names.includes(ref)) return ref; // exact path or virtual name
const suffix = `/${ref}`;
const matches = names.filter((n) => n.endsWith(suffix));
if (!matches.length) return ref;
// Shallowest path wins (folders act as defaults); ties broken by the
// guaranteed natural order (symbols < numbers < letters).
matches.sort((a, b) => {
const da = a.split("/").length;
const db = b.split("/").length;
return da === db ? compareNames(a, b) : da - db;
});
return matches[0];
}
/**
* The display token from the highest (closest-to-root) forced ancestor folder of `segs` down, or
* null when no ancestor folder is forced.
* @param {string[]} segs The name's `/`-split segments.
* @param {Set<string>} forced Folders marked with a `.force-prefix` file.
* @returns {string|null}
*/
function forcedToken(segs, forced) {
for (let k = 0; k < segs.length - 1; k++) {
if (forced.has(segs.slice(0, k + 1).join("/"))) return segs.slice(k).join("/");
}
return null;
}
/**
* Grow each auto name's shown-folder depth (in `shown`) by one per round until no two names share a
* token — the collision-resolution fixpoint.
* @param {string[]} auto The names being auto-tokenized.
* @param {Map<string, number>} shown name → folders currently shown (mutated).
* @param {(n: string) => string} tok Current token for a name given `shown`.
*/
function growUntilDistinct(auto, shown, tok) {
for (let changed = true; changed;) {
changed = false;
const groups = {};
for (const n of auto) {
const key = tok(n);
groups[key] ??= [];
groups[key].push(n);
}
for (const members of Object.values(groups)) {
if (members.length < 2) continue;
for (const n of members) {
if (shown.get(n) < n.split("/").length) {
shown.set(n, shown.get(n) + 1);
changed = true;
}
}
}
}
}
/**
* Compute the SHORTEST unambiguous display token for each list, for editor buttons.
* By default a list shows just its filename; a name only grows a folder prefix when
* it would otherwise be ambiguous.
*
* Two stages:
* 1. **Manual prefix (`.force-prefix`)** — any name under a folder marked with a
* `.force-prefix` file shows its path from the highest such ancestor down (e.g.
* `danbooru/d/general` → `d/general`). These are excluded from the auto step, so
* they never push a prefix onto anyone else.
* 2. **Auto prefix** — the rest start at the bare filename; whenever two share a
* token they each step out one more folder until distinct.
*
* A final pass guarantees every token `resolveName()`s back to its own canonical
* name (lengthening if a forced/other name would otherwise shadow it).
* @param {string[]} names Canonical (logical) list names.
* @param {string[]} [forcedDirs] Folders that contain a `.force-prefix` marker.
* @returns {Object<string,string>} Map of canonical name → display token.
*/
export function computeButtonNames(names, forcedDirs = []) {
const forced = new Set(forcedDirs);
const result = {};
const auto = [];
for (const name of names) {
const token = forcedToken(name.split("/"), forced);
if (token === null) auto.push(name);
else result[name] = token;
}
// Auto: bare filename, lengthened by one folder per round while any collide.
const shown = new Map(auto.map((n) => [n, 1]));
const tok = (n) => {
const s = n.split("/");
return s.slice(s.length - shown.get(n)).join("/");
};
growUntilDistinct(auto, shown, tok);
for (const n of auto) result[n] = tok(n);
// Guarantee each token resolves back to its own name (vs forced/other shadows).
for (const name of names) {
const segs = name.split("/");
let len = result[name].split("/").length;
while (resolveName(result[name], names) !== name && len < segs.length) {
len++;
result[name] = segs.slice(segs.length - len).join("/");
}
}
return result;
}