engine/listResolve.js

/**
 * @file
 * @brief List/group LINE resolution: turn a `{name}` reference into its lines, honoring the
 * SFW/NSFW naming model and `includeAdult`, including composite `.group` unions and implied-group
 * folders (with a depth + cycle guard). Pure, browser-safe; names resolve via ./nameOrder.js.
 *
 * A COMPOSITE list is a `.group` file: a union of other lists (resolved like any reference) rather
 * than a file on disk. There is NO runtime content filtering — SFW/NSFW are preprocessed into
 * separate files (`<name>-sfw.txt` / `<name>-nsfw.txt`), combined here per `includeAdult`.
 */

import { resolveName, RESERVED_WILDCARD, SFW_SUFFIX, NSFW_SUFFIX } from "./nameOrder.js";

/** Recursion cutoff for group-includes-group nesting. */
export const MAX_GROUP_DEPTH = 3;

/**
 * Read a plain list's SFW base lines. **Safety rule:** when a `<base>-nsfw` sibling
 * exists, a plain `<base>.txt` is IGNORED — the SFW source must be the explicit
 * `<base>-sfw.txt`. This enforces the naming split so a stray `<base>.txt` can never
 * leak as SFW alongside NSFW (a lone `<base>.txt` beside `<base>-nsfw.txt` is thus
 * treated as NSFW-only). With no `<base>-nsfw` sibling, a plain `<base>.txt` is a
 * normal SFW list (with `<base>-sfw.txt` as a fallback). Returns `null` when no SFW
 * source exists, else an array (possibly empty).
 * @param {string} base Canonical base name (no sfw/nsfw suffix).
 * @param {{names: string[], readListFile: function(string): (string[]|null)}} readers
 * @returns {string[]|null}
 */
function readSfwBase(base, readers) {
  if (readers.names.includes(`${base}-nsfw`)) return readers.readListFile(`${base}-sfw`);
  return readers.readListFile(base) ?? readers.readListFile(`${base}-sfw`);
}

/**
 * Member reference lines for an IMPLIED group (a `.force-group-list` folder): the
 * folder's OWN direct list files only (NOT descendants — implied groups don't stack),
 * de-duplicated to base names (so `-sfw`/`-nsfw` pairs become one member resolved
 * mode-aware), excluding real groups. The result feeds the normal group-union path.
 * @param {string} dir The folder path.
 * @param {{names: string[], readGroupFile: function(string): (string[]|null)}} readers
 * @returns {string[]} Member reference lines.
 */
function impliedGroupMembers(dir, readers) {
  const seen = new Set();
  const out = [];
  for (const n of readers.names) {
    if (!n.startsWith(`${dir}/`)) continue;
    if (n.slice(dir.length + 1).includes("/")) continue; // direct children only
    if (readers.readGroupFile(n) != null) continue; // skip real groups
    const b = n.replace(/-(sfw|nsfw)$/i, "");
    if (!seen.has(b)) {
      seen.add(b);
      out.push(b);
    }
  }
  return out;
}

/**
 * Folders that are IMPLIED groups: a folder with **2+ direct list files** is auto-marked
 * (referenceable as `{folder}` = union of its own lists). An `enable-group-list` marker
 * forces a folder on (even with one list); a `disable-group-list` marker forces it off.
 * Does NOT stack — only the folder's own direct lists count, not its subfolders.
 * @param {string[]} listNames Logical LIST names (txt-derived; groups excluded).
 * @param {string[]} [enableDirs] Folders forced on (`.enable-group-list`).
 * @param {string[]} [disableDirs] Folders forced off (`.disable-group-list`).
 * @returns {string[]} The implied-group folder paths.
 */
export function autoGroupListDirs(listNames, enableDirs = [], disableDirs = []) {
  const byDir = new Map(); // dir -> set of distinct base list names (variants collapsed)
  for (const n of listNames) {
    const base = n.replace(/-(sfw|nsfw)$/i, "");
    const i = base.lastIndexOf("/");
    if (i < 0) continue;
    const dir = base.slice(0, i);
    if (!byDir.has(dir)) byDir.set(dir, new Set());
    byDir.get(dir).add(base);
  }
  const dis = new Set(disableDirs);
  const en = new Set(enableDirs);
  const out = new Set();
  for (const [dir, bases] of byDir) {
    if (dis.has(dir)) continue;
    if (bases.size >= 2 || en.has(dir)) out.add(dir);
  }
  return [...out];
}

/**
 * Append the `\r`-stripped, non-empty, not-yet-seen lines from `lines` onto `out`.
 * @param {string[]} out Accumulator (mutated).
 * @param {Set<string>} seenLine Dedup set (mutated).
 * @param {string[]} lines Candidate lines.
 */
function pushUnique(out, seenLine, lines) {
  for (const l of lines) {
    const t = l.replace(/\r$/, "");
    if (t.trim() === "" || seenLine.has(t)) continue;
    seenLine.add(t);
    out.push(t);
  }
}

/**
 * Resolve a reference's base name + variant ("sfw" = SFW only, "full" = SFW+NSFW). An explicit
 * `-nsfw` reference is excluded (invisible) when adult is off or a parent forces SFW.
 * @returns {{base: string, variant: ("sfw"|"full"), excluded: false} | {excluded: true}}
 */
function resolveVariant(name, forced, includeAdult) {
  if (NSFW_SUFFIX.test(name)) {
    if (forced === "sfw" || !includeAdult) return { excluded: true };
    return { base: name.replace(NSFW_SUFFIX, ""), variant: "full", excluded: false };
  }
  if (SFW_SUFFIX.test(name)) {
    return { base: name.replace(SFW_SUFFIX, ""), variant: "sfw", excluded: false };
  }
  return { base: name, variant: forced ?? (includeAdult ? "full" : "sfw"), excluded: false };
}

/**
 * Reserved `keyword` wildcard: the union of ALL general vocabulary (mode-aware), excluding the
 * artist/* and danbooru/* namespaces, groups (covered via their lists), and itself.
 * @returns {string[]} De-duplicated lines.
 */
function resolveWildcard(readers, includeAdult, variant, depth, seen) {
  const out = [];
  const seenLine = new Set();
  const bases = new Set();
  for (const n of readers.names) {
    if (n.includes("artist") || n.startsWith("danbooru/")) continue;
    if (readers.readGroupFile(n) != null) continue; // members covered via their lists
    if (readers.groupListDirs?.includes(n)) continue; // implied group dir
    const b = n.replace(/-(sfw|nsfw)$/i, "");
    if (b === RESERVED_WILDCARD) continue;
    bases.add(b);
  }
  for (const b of bases) {
    pushUnique(
      out,
      seenLine,
      resolveListLines(b, readers, includeAdult, variant, depth + 1, seen) || [],
    );
  }
  return out;
}

/**
 * A group (real `.group` file or implied-group folder): the de-duplicated union of its members'
 * lines, propagating the resolved variant, with the depth + cycle guard.
 * @returns {string[]} De-duplicated lines.
 */
function resolveGroup(base, groupLines, readers, includeAdult, variant, depth, seen) {
  if (seen.has(base) || depth >= MAX_GROUP_DEPTH) return [];
  seen.add(base);
  const out = [];
  const seenLine = new Set();
  for (const raw of groupLines) {
    const line = raw.replace(/\r$/, "").trim();
    if (line === "" || line.startsWith("#") || line.startsWith("@")) continue;
    const member = resolveName(line, readers.names);
    pushUnique(
      out,
      seenLine,
      resolveListLines(member, readers, includeAdult, variant, depth + 1, seen) || [],
    );
  }
  return out;
}

/**
 * Resolve a list/group reference to its lines, honoring the SFW/NSFW naming model
 * and the `includeAdult` mode. No runtime content filtering — NSFW is a separate
 * preprocessed `<base>-nsfw.txt` file that is simply included or not.
 *
 * Semantics (per reference):
 * - `{name}`        → SFW only when adult is off; SFW + `<name>-nsfw` when on.
 * - `{name-sfw}`    → SFW base only (always; the explicit SFW-exclusive reference).
 * - `{name-nsfw}`   → nothing when adult is off (acts as if it doesn't exist);
 *                     SFW + `<name>-nsfw` when on (the SFW base is auto-tacked on).
 *
 * Groups propagate the resolved variant to their members, so `{d}` (off) is
 * all-SFW, `{d}` (on) includes NSFW, and `{d-sfw}` is SFW even when on.
 *
 * @param {string} name Canonical list/group name (may carry a `-sfw`/`-nsfw` suffix).
 * @param {{names: string[], readListFile: function(string): (string[]|null), readGroupFile: function(string): (string[]|null)}} readers
 * @param {boolean} [includeAdult] Whether adult/NSFW content is enabled.
 * @param {("sfw"|"full"|null)} [forced] Variant forced by a parent group (internal).
 * @param {number} [depth] Current group-nesting depth (internal).
 * @param {Set<string>} [seen] Cycle guard (internal).
 * @returns {string[]|null} Resolved lines, or null if a plain list is missing.
 */
export function resolveListLines(
  name,
  readers,
  includeAdult = false,
  forced = null,
  depth = 0,
  seen = new Set(),
) {
  // Determine the base name and the variant ("sfw" = SFW only, "full" = SFW+NSFW).
  const v = resolveVariant(name, forced, includeAdult);
  if (v.excluded) return [];
  let base = v.base;
  const variant = v.variant;

  // Reserved `keyword` wildcard: a random word from ALL general vocabulary, drawn
  // mode-aware. Not a file — supersedes any list literally named `keyword`. Excludes
  // the specialized artist/* and danbooru/* namespaces (they have their own modes),
  // and of course excludes itself. `{keyword}` = SFW off / +NSFW on; `{keyword-sfw}`
  // = SFW always; `{keyword-nsfw}` = SFW+NSFW (and invisible when adult is off, handled
  // by the -nsfw suffix branch above).
  if (base === RESERVED_WILDCARD) {
    return resolveWildcard(readers, includeAdult, variant, depth, seen);
  }

  // Re-resolve the (suffix-stripped) base to its canonical name, so an explicit
  // variant like {d-sfw} maps to the group/list path {d} resolves to.
  base = resolveName(base, readers.names);

  // Group? Either a real `.group` file, or an IMPLIED group: a folder marked with a
  // `.force-group-list` file resolves to the union of all lists directly/under it.
  let groupLines = readers.readGroupFile(base);
  if (groupLines == null && readers.groupListDirs?.includes(base)) {
    groupLines = impliedGroupMembers(base, readers);
  }
  if (groupLines != null) {
    return resolveGroup(base, groupLines, readers, includeAdult, variant, depth, seen);
  }

  // Plain list. SFW base + (NSFW extra when the variant is full).
  const sfw = readSfwBase(base, readers);
  if (variant === "sfw") return sfw == null ? null : [...sfw];
  const nsfw = readers.readListFile(`${base}-nsfw`) ?? [];
  if (sfw == null && nsfw.length === 0) return null;
  return [...(sfw ?? []), ...nsfw];
}