@kecan0406/ttheme 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ttheme.js CHANGED
@@ -6144,7 +6144,7 @@ var program = new Command;
6144
6144
  // package.json
6145
6145
  var package_default = {
6146
6146
  name: "@kecan0406/ttheme",
6147
- version: "0.1.2",
6147
+ version: "0.1.3",
6148
6148
  type: "module",
6149
6149
  bin: {
6150
6150
  ttheme: "bin/ttheme.js"
@@ -6804,6 +6804,42 @@ var CONFIG_SETTINGS = {
6804
6804
  TTHEME_SORT: {
6805
6805
  doc: "# series and palettes in ttheme and preview: abc sorts them by name, series keeps the order they were added (default abc)",
6806
6806
  default: "abc"
6807
+ },
6808
+ TTHEME_FIND_RATING: {
6809
+ doc: "# the ratings find lists, any of safe, questionable and explicit, each booru read in its own rating vocabulary (default safe)",
6810
+ default: "safe"
6811
+ },
6812
+ TTHEME_FIND_BLOCK: {
6813
+ doc: '# the posts find drops by tag: nudity, underwear, both, or none to keep them all (default "nudity underwear")',
6814
+ default: "nudity underwear"
6815
+ },
6816
+ TTHEME_FIND_POSTS: {
6817
+ doc: "# what find lists first: all is every post of the character, cutouts are the transparent ones (default all)",
6818
+ default: "all"
6819
+ },
6820
+ TTHEME_FIND_CUTOUTS: {
6821
+ doc: '# the tags find calls a transparent cutout, per site as key=tag,tag pairs — e.g. "safebooru=transparent_background yande=transparent_png,vector" (default the built-in tags)',
6822
+ default: ""
6823
+ },
6824
+ TTHEME_FIND_ORDER: {
6825
+ doc: "# the order find lists posts in: newest or score (default newest)",
6826
+ default: "newest"
6827
+ },
6828
+ TTHEME_FIND_SETS: {
6829
+ doc: "# runs of the same picture at the same size from one uploader: fold shows them as one tile, show lists each (default fold)",
6830
+ default: "fold"
6831
+ },
6832
+ TTHEME_FIND_REMOVE_BG: {
6833
+ doc: "# an opaque picture tried on in find: on cuts the character out with macOS Vision, off leaves it as it is (default on)",
6834
+ default: "on"
6835
+ },
6836
+ TTHEME_FIND_UNBLOCK: {
6837
+ doc: "# when a network blocks a booru by name, 1 sends find through a local proxy that splits the TLS handshake (default 0)",
6838
+ default: "0"
6839
+ },
6840
+ TTHEME_FIND_HOSTS: {
6841
+ doc: '# send a find site somewhere else, as key=https://host pairs — e.g. "konachan=https://konachan.com danbooru=https://danbooru.donmai.us" (default none)',
6842
+ default: ""
6807
6843
  }
6808
6844
  };
6809
6845
  function settingLine(name, value) {
@@ -6831,6 +6867,17 @@ ${settingLine(name, s.default)}`);
6831
6867
  `)}
6832
6868
  `;
6833
6869
  }
6870
+ function withSetting(content, name, value) {
6871
+ const line = settingLine(name, value);
6872
+ const pattern = settingPattern(name);
6873
+ if (pattern.test(content)) {
6874
+ return content.replace(pattern, line);
6875
+ }
6876
+ return `${content.replace(/\n*$/, `
6877
+ `)}
6878
+ ${line}
6879
+ `;
6880
+ }
6834
6881
  function configFile(content) {
6835
6882
  if (content === "") {
6836
6883
  return configTemplate();
@@ -6848,7 +6895,7 @@ import = ["${themePath}"]` : "";
6848
6895
  // src/emit/shell.ts
6849
6896
  function palettesZsh(palettes) {
6850
6897
  for (const p of palettes) {
6851
- for (const field of [p.name, p.group, p.native ?? "", p.ansiSource, p.booru ?? ""]) {
6898
+ for (const field of [p.name, p.group, p.native ?? "", p.ansiSource]) {
6852
6899
  if (/["$`\\]/.test(field)) {
6853
6900
  throw new Error(`${p.name}: "${field}" contains a character that breaks zsh quoting`);
6854
6901
  }
@@ -6890,11 +6937,6 @@ function palettesZsh(palettes) {
6890
6937
  "typeset -gA TTHEME_SRC=(",
6891
6938
  ...palettes.map((p) => entry(p, p.ansiSource)),
6892
6939
  ")",
6893
- "",
6894
- "# the safebooru tag preview searches when a palette has no background yet",
6895
- "typeset -gA TTHEME_BOORU=(",
6896
- ...palettes.filter((p) => p.booru).map((p) => entry(p, p.booru ?? "")),
6897
- ")",
6898
6940
  ""
6899
6941
  ].join(`
6900
6942
  `);
@@ -6999,7 +7041,8 @@ ${themes.length} themes (${rotation(themes).length} in the new-tab rotation) ->
6999
7041
  }
7000
7042
 
7001
7043
  // src/find.ts
7002
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
7044
+ import { spawn as spawn2 } from "node:child_process";
7045
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync5, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
7003
7046
  import { tmpdir } from "node:os";
7004
7047
  import { dirname as dirname3, join as join7 } from "node:path";
7005
7048
 
@@ -7023,12 +7066,21 @@ function decodePng(bytes) {
7023
7066
  const png = import_pngjs.PNG.sync.read(Buffer.from(bytes));
7024
7067
  return { width: png.width, height: png.height, data: new Uint8Array(png.data) };
7025
7068
  }
7026
- function decodeImage(bytes) {
7069
+ function decodeImage(bytes, limit) {
7027
7070
  if (isPng(bytes)) {
7071
+ const head = pngHead(bytes);
7072
+ if (head && head.width * head.height > limit) {
7073
+ throw new Error(`${head.width}×${head.height} is over ${limit / 1e6} megapixels`);
7074
+ }
7028
7075
  return decodePng(bytes);
7029
7076
  }
7030
7077
  if (bytes[0] === 255 && bytes[1] === 216) {
7031
- const jpeg = import_jpeg_js.decode(bytes, { useTArray: true, formatAsRGBA: true, maxMemoryUsageInMB: 1024 });
7078
+ const jpeg = import_jpeg_js.decode(bytes, {
7079
+ useTArray: true,
7080
+ formatAsRGBA: true,
7081
+ maxResolutionInMP: limit / 1e6,
7082
+ maxMemoryUsageInMB: 1024
7083
+ });
7032
7084
  return { width: jpeg.width, height: jpeg.height, data: jpeg.data };
7033
7085
  }
7034
7086
  throw new Error("not a PNG or JPEG image");
@@ -7060,9 +7112,8 @@ function pngHead(bytes) {
7060
7112
  if (bytes[25] === 4 || bytes[25] === 6) {
7061
7113
  return { width, height, alpha: true };
7062
7114
  }
7063
- const trns = find(bytes, "tRNS");
7064
7115
  const idat = find(bytes, "IDAT");
7065
- return { width, height, alpha: trns !== -1 && (idat === -1 || trns < idat) };
7116
+ return { width, height, alpha: find(idat === -1 ? bytes : bytes.subarray(0, idat), "tRNS") !== -1 };
7066
7117
  }
7067
7118
  function alphaBox(image, threshold = CLEAR) {
7068
7119
  let x0 = image.width;
@@ -7364,14 +7415,30 @@ function origins(configHome) {
7364
7415
  }
7365
7416
 
7366
7417
  // src/booru.ts
7418
+ import { setDefaultAutoSelectFamilyAttemptTimeout } from "node:net";
7367
7419
  import { homedir } from "node:os";
7368
7420
  import { join as join4 } from "node:path";
7421
+ import { setTimeout as sleep } from "node:timers/promises";
7369
7422
  var PAGE = 100;
7370
- var SAFE = new Set(["safe", "general", "s", "g"]);
7423
+ var MAX_PIXELS = 25000000;
7424
+ var RATINGS = ["safe", "questionable", "explicit"];
7425
+ var BLOCKS = ["nudity", "underwear"];
7426
+ var EXPOSED = {
7427
+ nudity: new Set(["nude", "naked", "topless", "bottomless", "nipples", "naked_towel", "undressing"]),
7428
+ underwear: new Set(["underwear", "panties", "pantsu", "bra", "lingerie", "pantyshot"])
7429
+ };
7430
+ var MOEBOORU = { safe: "s", questionable: "q", explicit: "e" };
7371
7431
  var MIRRORS = new Set(["danbooru", "gelbooru", "konachan", "yande.re", "sankaku"]);
7372
7432
  var HEAD = 8191;
7373
7433
  var TIMEOUT = 20000;
7434
+ var CONNECT = 3000;
7435
+ var MAX_WAIT = 60000;
7436
+ var TRIES = 3;
7374
7437
  var AGENT = `ttheme/${package_default.version} (+${package_default.homepage})`;
7438
+ var paused = new Map;
7439
+ var HOSTS = hostMap(process.env.TTHEME_FIND_HOSTS);
7440
+ var CUTOUTS = cutoutMap(process.env.TTHEME_FIND_CUTOUTS);
7441
+ setDefaultAutoSelectFamilyAttemptTimeout(CONNECT);
7375
7442
  function absolute(url) {
7376
7443
  return url.startsWith("//") ? `https:${url}` : url;
7377
7444
  }
@@ -7384,26 +7451,45 @@ function records(text) {
7384
7451
  return [];
7385
7452
  }
7386
7453
  const raw = JSON.parse(body);
7387
- return Array.isArray(raw) ? raw : [];
7454
+ if (Array.isArray(raw)) {
7455
+ return raw;
7456
+ }
7457
+ const posts = raw.posts;
7458
+ return Array.isArray(posts) ? posts : [];
7459
+ }
7460
+ function tagTypes(text) {
7461
+ const body = text.trim();
7462
+ const raw = body ? JSON.parse(body) : {};
7463
+ const tags = raw.tags ?? {};
7464
+ return new Map(Object.entries(tags).map(([name, type]) => [name, String(type)]));
7388
7465
  }
7389
- function post(p, file, ext, owner, md5) {
7390
- const preview = absolute(String(p.preview_url ?? ""));
7391
- if (!file || !preview || !Number(p.id)) {
7466
+ function version(url, width, height) {
7467
+ const file = absolute(String(url ?? ""));
7468
+ return { file, width: Number(width) || 0, height: Number(height) || 0, ext: extension(file) };
7469
+ }
7470
+ function post(p, raw) {
7471
+ const preview = absolute(String(raw.preview ?? ""));
7472
+ if (!raw.file || !preview || !Number(p.id)) {
7392
7473
  return [];
7393
7474
  }
7475
+ const width = Number(p.width ?? p.image_width) || 0;
7476
+ const height = Number(p.height ?? p.image_height) || 0;
7394
7477
  return [
7395
7478
  {
7396
7479
  id: Number(p.id),
7397
- width: Number(p.width) || 0,
7398
- height: Number(p.height) || 0,
7399
- file,
7480
+ width,
7481
+ height,
7482
+ file: raw.file,
7400
7483
  preview,
7401
- ext,
7402
- owner: String(owner ?? ""),
7484
+ ext: raw.ext,
7485
+ owner: String(raw.owner ?? ""),
7486
+ artist: raw.artist,
7487
+ score: Number(p.score) || 0,
7403
7488
  rating: String(p.rating ?? ""),
7404
- md5: String(md5 ?? ""),
7489
+ md5: String(raw.md5 ?? ""),
7405
7490
  source: String(p.source ?? ""),
7406
- tags: String(p.tags ?? "").split(/\s+/).filter(Boolean)
7491
+ tags: raw.tags.split(/\s+/).filter(Boolean),
7492
+ smaller: raw.versions.filter((v) => v.file && v.width > 0 && v.height > 0 && v.width * v.height < width * height)
7407
7493
  }
7408
7494
  ];
7409
7495
  }
@@ -7411,54 +7497,239 @@ function parseGelbooru(text, origin = "https://safebooru.org") {
7411
7497
  return records(text).flatMap((p) => {
7412
7498
  const stored = p.directory && p.image ? `${origin}/images/${p.directory}/${p.image}` : "";
7413
7499
  const file = absolute(String(p.file_url || stored));
7414
- return post(p, file, extension(file), p.owner, p.hash ?? p.md5);
7500
+ return post(p, {
7501
+ preview: p.preview_url,
7502
+ file,
7503
+ ext: extension(file),
7504
+ owner: p.owner,
7505
+ artist: "",
7506
+ md5: p.hash ?? p.md5,
7507
+ tags: String(p.tags ?? ""),
7508
+ versions: [version(p.sample_url, p.sample_width, p.sample_height)]
7509
+ });
7415
7510
  });
7416
7511
  }
7417
7512
  function parseMoebooru(text) {
7513
+ const types = tagTypes(text);
7418
7514
  return records(text).flatMap((p) => {
7419
7515
  const file = absolute(String(p.file_url ?? ""));
7420
- return post(p, file, String(p.file_ext || extension(file)).toLowerCase(), p.author, p.md5);
7516
+ const tags = String(p.tags ?? "");
7517
+ return post(p, {
7518
+ preview: p.preview_url,
7519
+ file,
7520
+ ext: String(p.file_ext || extension(file)).toLowerCase(),
7521
+ owner: p.author,
7522
+ artist: tags.split(/\s+/).find((tag) => types.get(tag) === "artist") ?? "",
7523
+ md5: p.md5,
7524
+ tags,
7525
+ versions: [
7526
+ version(p.jpeg_url, p.jpeg_width, p.jpeg_height),
7527
+ version(p.sample_url, p.sample_width, p.sample_height)
7528
+ ]
7529
+ });
7530
+ });
7531
+ }
7532
+ function parseDanbooru(text) {
7533
+ return records(text).flatMap((p) => {
7534
+ const file = absolute(String(p.file_url ?? ""));
7535
+ const asset = p.media_asset ?? {};
7536
+ return post(p, {
7537
+ preview: p.preview_file_url,
7538
+ file,
7539
+ ext: String(p.file_ext || extension(file)).toLowerCase(),
7540
+ owner: "",
7541
+ artist: String(p.tag_string_artist ?? "").split(/\s+/)[0] ?? "",
7542
+ md5: p.md5,
7543
+ tags: String(p.tag_string ?? ""),
7544
+ versions: (asset.variants ?? []).map((v) => version(v.url, v.width, v.height)).filter((v) => v.ext === "jpg" || v.ext === "png").sort((a, b) => b.width * b.height - a.width * a.height)
7545
+ });
7421
7546
  });
7422
7547
  }
7423
7548
  function parseCount(xml) {
7424
7549
  return Number(/count="(\d+)"/.exec(xml)?.[1] ?? 0);
7425
7550
  }
7426
- function gelbooru(key, name, origin, cutouts, vouched, ansi) {
7427
- const api = (params) => `${origin}/index.php?${new URLSearchParams({ page: "dapi", s: "post", q: "index", ...params })}`;
7551
+ function parseCounts(text) {
7552
+ const raw = text.trim() ? JSON.parse(text) : {};
7553
+ return Number(raw.counts?.posts) || 0;
7554
+ }
7555
+ function cutoutMap(spec) {
7556
+ const cutouts = new Map;
7557
+ for (const entry of (spec ?? "").split(/\s+/).filter(Boolean)) {
7558
+ const at2 = entry.indexOf("=");
7559
+ if (at2 > 0) {
7560
+ cutouts.set(entry.slice(0, at2), entry.slice(at2 + 1).split(",").filter(Boolean));
7561
+ }
7562
+ }
7563
+ return cutouts;
7564
+ }
7565
+ function hostMap(spec) {
7566
+ const hosts = new Map;
7567
+ for (const entry of (spec ?? "").split(/[\s,]+/).filter(Boolean)) {
7568
+ const at2 = entry.indexOf("=");
7569
+ const key = entry.slice(0, at2);
7570
+ try {
7571
+ const url = new URL(entry.slice(at2 + 1));
7572
+ if (key && url.protocol === "https:") {
7573
+ hosts.set(key, url.origin);
7574
+ }
7575
+ } catch {}
7576
+ }
7577
+ return hosts;
7578
+ }
7579
+ function chosen(value, all, fallback, none) {
7580
+ const words = (value ?? "").split(/\s+/);
7581
+ if (none !== undefined && words.includes(none)) {
7582
+ return [];
7583
+ }
7584
+ const picked = all.filter((item) => words.includes(item));
7585
+ return picked.length > 0 ? picked : [...fallback];
7586
+ }
7587
+ function ratingSet(value) {
7588
+ return chosen(value, RATINGS, ["safe"]);
7589
+ }
7590
+ function blockSet(value) {
7591
+ return chosen(value, BLOCKS, BLOCKS, "none");
7592
+ }
7593
+ function tiers(safe, questionable, explicit) {
7594
+ return { safe: new Set(safe), questionable: new Set(questionable), explicit: new Set(explicit) };
7595
+ }
7596
+ function based(spec) {
7597
+ const origin = HOSTS.get(spec.key);
7598
+ const cutouts = CUTOUTS.get(spec.key);
7428
7599
  return {
7429
- key,
7430
- name,
7431
- origin,
7432
- cutouts,
7433
- vouched,
7434
- ansi,
7600
+ ...spec,
7601
+ origin: origin ?? spec.origin,
7602
+ moved: origin !== undefined && origin !== spec.origin,
7603
+ cutouts: cutouts ?? spec.cutouts,
7604
+ vouched: spec.vouched && cutouts === undefined
7605
+ };
7606
+ }
7607
+ function anyOf(tags, grouped) {
7608
+ if (tags.length < 2) {
7609
+ return tags.join("");
7610
+ }
7611
+ return grouped ? `( ${tags.join(" ~ ")} )` : tags.map((tag) => `~${tag}`).join(" ");
7612
+ }
7613
+ function gelbooru(raw) {
7614
+ const spec = based(raw);
7615
+ const api = (params) => `${spec.origin}/index.php?${new URLSearchParams({ page: "dapi", s: "post", q: "index", ...params })}`;
7616
+ return {
7617
+ ...spec,
7618
+ cutouts: anyOf(spec.cutouts, true),
7619
+ ratings: tiers(["safe", "general"], ["questionable"], ["explicit"]),
7620
+ rate: () => "",
7621
+ best: "sort:score:desc",
7622
+ tagBudget: Number.POSITIVE_INFINITY,
7435
7623
  postsUrl: (tags, page) => api({ json: "1", limit: String(PAGE), pid: String(page), tags }),
7436
7624
  countUrl: (tags) => api({ limit: "0", tags }),
7437
7625
  postUrl: (id) => api({ json: "1", id: String(id) }),
7438
- pageUrl: (id) => `${origin}/index.php?page=post&s=view&id=${id}`,
7439
- parse: (text) => parseGelbooru(text, origin)
7626
+ pageUrl: (id) => `${spec.origin}/index.php?page=post&s=view&id=${id}`,
7627
+ parse: (text) => parseGelbooru(text, spec.origin),
7628
+ count: parseCount
7440
7629
  };
7441
7630
  }
7442
- function moebooru(key, name, origin, cutouts, vouched, ansi) {
7443
- const safe = (tags) => `${tags} rating:s`;
7631
+ function moebooru(raw) {
7632
+ const spec = based(raw);
7633
+ const params = (rest) => new URLSearchParams({ api_version: "2", include_tags: "1", ...rest });
7444
7634
  return {
7445
- key,
7446
- name,
7447
- origin,
7448
- cutouts,
7449
- vouched,
7450
- ansi,
7451
- postsUrl: (tags, page) => `${origin}/post.json?${new URLSearchParams({ limit: String(PAGE), page: String(page + 1), tags: safe(tags) })}`,
7452
- countUrl: (tags) => `${origin}/post.xml?${new URLSearchParams({ limit: "1", tags: safe(tags) })}`,
7453
- postUrl: (id) => `${origin}/post.json?${new URLSearchParams({ tags: `id:${id}` })}`,
7454
- pageUrl: (id) => `${origin}/post/show/${id}`,
7455
- parse: parseMoebooru
7635
+ ...spec,
7636
+ cutouts: anyOf(spec.cutouts, false),
7637
+ ratings: tiers([MOEBOORU.safe], [MOEBOORU.questionable], [MOEBOORU.explicit]),
7638
+ rate: (levels) => {
7639
+ const [only] = levels;
7640
+ if (levels.length === 1 && only) {
7641
+ return `rating:${MOEBOORU[only]}`;
7642
+ }
7643
+ const missing = RATINGS.filter((level) => !levels.includes(level));
7644
+ const [left] = missing;
7645
+ return missing.length === 1 && left ? `-rating:${MOEBOORU[left]}` : "";
7646
+ },
7647
+ best: "order:score",
7648
+ tagBudget: Number.POSITIVE_INFINITY,
7649
+ postsUrl: (tags, page) => `${spec.origin}/post.json?${params({ limit: String(PAGE), page: String(page + 1), tags })}`,
7650
+ countUrl: (tags) => `${spec.origin}/post.xml?${new URLSearchParams({ limit: "1", tags })}`,
7651
+ postUrl: (id) => `${spec.origin}/post.json?${params({ tags: `id:${id}` })}`,
7652
+ pageUrl: (id) => `${spec.origin}/post/show/${id}`,
7653
+ parse: parseMoebooru,
7654
+ count: parseCount
7456
7655
  };
7457
7656
  }
7657
+ function danbooru(raw) {
7658
+ const spec = based(raw);
7659
+ return {
7660
+ ...spec,
7661
+ cutouts: anyOf(spec.cutouts, false),
7662
+ ratings: tiers(["g"], ["s", "q"], ["e"]),
7663
+ rate: () => "",
7664
+ best: "order:score",
7665
+ tagBudget: 2,
7666
+ postsUrl: (tags, page) => `${spec.origin}/posts.json?${new URLSearchParams({ limit: String(PAGE), page: String(page + 1), tags })}`,
7667
+ countUrl: (tags) => `${spec.origin}/counts/posts.json?${new URLSearchParams({ tags })}`,
7668
+ postUrl: (id) => `${spec.origin}/posts.json?${new URLSearchParams({ limit: "1", tags: `id:${id}` })}`,
7669
+ pageUrl: (id) => `${spec.origin}/posts/${id}`,
7670
+ parse: parseDanbooru,
7671
+ count: parseCounts
7672
+ };
7673
+ }
7674
+ function siteNamed(name) {
7675
+ return SITES.find((site) => site.key === name || site.name === name || new URL(site.origin).host === name);
7676
+ }
7677
+ function tagsOf(query) {
7678
+ return query.split(/\s+/).filter(Boolean).length;
7679
+ }
7680
+ function postRef(text, fallback) {
7681
+ const query = text.trim();
7682
+ if (/^\d+$/.test(query)) {
7683
+ return { site: fallback, id: Number(query) };
7684
+ }
7685
+ const named = /^([\w.]+):(\d+)$/.exec(query);
7686
+ if (named) {
7687
+ const site2 = SITES.find((s) => s.key === named[1] || s.name === named[1]);
7688
+ return site2 && { site: site2, id: Number(named[2]) };
7689
+ }
7690
+ let url;
7691
+ try {
7692
+ url = new URL(query);
7693
+ } catch {
7694
+ return;
7695
+ }
7696
+ const site = siteNamed(url.host);
7697
+ const id = Number(url.searchParams.get("id") ?? url.pathname.split("/").filter(Boolean).at(-1));
7698
+ return site && id > 0 ? { site, id } : undefined;
7699
+ }
7458
7700
  var SITES = [
7459
- gelbooru("safebooru", "safebooru", "https://safebooru.org", "( transparent_background ~ vector_trace )", false, 4),
7460
- moebooru("yande", "yande.re", "https://yande.re", "transparent_png", true, 5),
7461
- moebooru("konachan", "konachan", "https://konachan.net", "~transparent ~vector", false, 6)
7701
+ gelbooru({
7702
+ key: "safebooru",
7703
+ name: "safebooru",
7704
+ origin: "https://safebooru.org",
7705
+ cutouts: ["transparent_background", "vector_trace"],
7706
+ vouched: false,
7707
+ ansi: 4
7708
+ }),
7709
+ moebooru({
7710
+ key: "yande",
7711
+ name: "yande.re",
7712
+ origin: "https://yande.re",
7713
+ cutouts: ["transparent_png"],
7714
+ vouched: true,
7715
+ ansi: 5
7716
+ }),
7717
+ moebooru({
7718
+ key: "konachan",
7719
+ name: "konachan",
7720
+ origin: "https://konachan.net",
7721
+ cutouts: ["transparent", "vector"],
7722
+ vouched: false,
7723
+ ansi: 6
7724
+ }),
7725
+ danbooru({
7726
+ key: "danbooru",
7727
+ name: "danbooru",
7728
+ origin: "https://safebooru.donmai.us",
7729
+ cutouts: ["transparent_background"],
7730
+ vouched: false,
7731
+ ansi: 2
7732
+ })
7462
7733
  ];
7463
7734
  function originHost(source) {
7464
7735
  let host;
@@ -7469,8 +7740,17 @@ function originHost(source) {
7469
7740
  }
7470
7741
  return host.split(".").slice(-2).join(".");
7471
7742
  }
7472
- function safe(post2) {
7473
- return SAFE.has(post2.rating);
7743
+ function exposed(post2, blocks = BLOCKS) {
7744
+ return post2.tags.filter((tag) => blocks.some((block2) => EXPOSED[block2].has(tag)));
7745
+ }
7746
+ function rated(site, post2, levels = ["safe"]) {
7747
+ return levels.some((level) => site.ratings[level].has(post2.rating));
7748
+ }
7749
+ function rendition(post2) {
7750
+ return [post2, ...post2.smaller].find((v) => v.width * v.height <= MAX_PIXELS);
7751
+ }
7752
+ function mirrored(owner) {
7753
+ return MIRRORS.has(owner);
7474
7754
  }
7475
7755
  function mates(owners) {
7476
7756
  const byOwner = new Map;
@@ -7485,21 +7765,50 @@ function mates(owners) {
7485
7765
  function cacheDir(site) {
7486
7766
  return join4(process.env.XDG_CACHE_HOME ?? join4(homedir(), ".cache"), "ttheme", site.key);
7487
7767
  }
7768
+ function retryAfter(value, now) {
7769
+ if (value !== null && /^\s*\d+\s*$/.test(value)) {
7770
+ return Number(value) * 1000;
7771
+ }
7772
+ const at2 = value === null ? Number.NaN : Date.parse(value);
7773
+ return Number.isNaN(at2) ? MAX_WAIT : Math.max(0, at2 - now);
7774
+ }
7775
+ function pausedUntil(site) {
7776
+ return paused.get(site.key) ?? 0;
7777
+ }
7778
+ function challenged(response) {
7779
+ return (response.headers.get("server") ?? "").startsWith("cloudflare") && response.headers.get("cf-mitigated") === "challenge";
7780
+ }
7488
7781
  async function get(site, url, signal, headers = {}, timeout = TIMEOUT) {
7489
- const response = await fetch(url, {
7490
- headers: { "User-Agent": AGENT, Referer: `${site.origin}/`, ...headers },
7491
- signal: timeout ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
7492
- });
7493
- if (!response.ok) {
7494
- throw new Error(`${site.name} answered ${response.status}`);
7782
+ for (let tries = 1;; tries++) {
7783
+ const wait = pausedUntil(site) - Date.now();
7784
+ if (wait > 0) {
7785
+ await sleep(wait, undefined, { signal });
7786
+ }
7787
+ const response = await fetch(url, {
7788
+ headers: { "User-Agent": AGENT, Referer: `${site.origin}/`, ...headers },
7789
+ signal: timeout ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
7790
+ });
7791
+ if (response.status === 429 && tries < TRIES) {
7792
+ await response.body?.cancel();
7793
+ const delay = retryAfter(response.headers.get("retry-after"), Date.now());
7794
+ if (delay > MAX_WAIT) {
7795
+ throw new Error(`${site.name} asks to wait ${Math.ceil(delay / 1000)}s`);
7796
+ }
7797
+ paused.set(site.key, Math.max(pausedUntil(site), Date.now() + delay));
7798
+ continue;
7799
+ }
7800
+ if (!response.ok) {
7801
+ await response.body?.cancel();
7802
+ throw new Error(challenged(response) ? `${site.name} is behind a Cloudflare challenge` : `${site.name} answered ${response.status}`);
7803
+ }
7804
+ return response;
7495
7805
  }
7496
- return response;
7497
7806
  }
7498
7807
  async function fetchPosts(site, tags, page, signal) {
7499
7808
  return site.parse(await (await get(site, site.postsUrl(tags, page), signal)).text());
7500
7809
  }
7501
7810
  async function fetchCount(site, tags, signal) {
7502
- return parseCount(await (await get(site, site.countUrl(tags), signal)).text());
7811
+ return site.count(await (await get(site, site.countUrl(tags), signal)).text());
7503
7812
  }
7504
7813
  async function fetchPost(site, id, signal) {
7505
7814
  const [found] = site.parse(await (await get(site, site.postUrl(id), signal)).text());
@@ -7614,8 +7923,66 @@ function search(palettes, query) {
7614
7923
  return palettes.filter((p) => [p.name, p.group, p.native ?? "", p.ansiSource].some((field) => field.toLowerCase().includes(needle)));
7615
7924
  }
7616
7925
 
7926
+ // src/cutout.ts
7927
+ import { spawn } from "node:child_process";
7928
+ import { readFileSync as readFileSync3 } from "node:fs";
7929
+ var TIMEOUT2 = 30000;
7930
+ var LEAST = 3;
7931
+ var MOST = 97;
7932
+ var SCRIPT = `ObjC.import('Vision')
7933
+ ObjC.import('CoreImage')
7934
+ ObjC.import('CoreGraphics')
7935
+ function run(argv) {
7936
+ const error = Ref()
7937
+ const image = $.CIImage.imageWithContentsOfURL($.NSURL.fileURLWithPath(argv[0]))
7938
+ if (!image) throw new Error('unreadable image')
7939
+ const handler = $.VNImageRequestHandler.alloc.initWithCIImageOptions(image, $({}))
7940
+ const request = $.VNGenerateForegroundInstanceMaskRequest.alloc.init
7941
+ if (!handler.performRequestsError($([request]), error)) throw new Error('vision failed')
7942
+ if (request.results.count === 0) throw new Error('no foreground')
7943
+ const found = request.results.objectAtIndex(0)
7944
+ const buffer = found.generateMaskedImageOfInstancesFromRequestHandlerCroppedToInstancesExtentError(
7945
+ found.allInstances, handler, false, error)
7946
+ if (!buffer) throw new Error('mask failed')
7947
+ const written = $.CIContext.context.writePNGRepresentationOfImageToURLFormatColorSpaceOptionsError(
7948
+ $.CIImage.imageWithCVPixelBuffer(buffer), $.NSURL.fileURLWithPath(argv[1]), $.kCIFormatRGBA8,
7949
+ $.CGColorSpaceCreateWithName($.kCGColorSpaceSRGB), $({}), error)
7950
+ if (!written) throw new Error('write failed')
7951
+ }`;
7952
+ function canRemoveBackground() {
7953
+ return process.platform === "darwin";
7954
+ }
7955
+ function keepable(clear) {
7956
+ return clear >= LEAST && clear <= MOST;
7957
+ }
7958
+ function removeBackground(input, output, signal) {
7959
+ return new Promise((resolve, reject) => {
7960
+ const child = spawn("osascript", ["-l", "JavaScript", "-e", SCRIPT, input, output], {
7961
+ stdio: ["ignore", "ignore", "pipe"],
7962
+ signal: AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT2)])
7963
+ });
7964
+ let failure = "";
7965
+ child.stderr.on("data", (chunk) => {
7966
+ failure += chunk;
7967
+ });
7968
+ child.on("error", reject);
7969
+ child.on("close", (code) => {
7970
+ if (code !== 0) {
7971
+ reject(new Error(failure.trim().split(`
7972
+ `).at(-1) || `osascript exited ${code}`));
7973
+ return;
7974
+ }
7975
+ try {
7976
+ resolve(decodePng(new Uint8Array(readFileSync3(output))));
7977
+ } catch (error) {
7978
+ reject(error);
7979
+ }
7980
+ });
7981
+ });
7982
+ }
7983
+
7617
7984
  // src/find-screen.ts
7618
- var TILE = { pitch: 25, cols: 22, rows: 9, height: 12 };
7985
+ var TILE = { pitch: 25, cols: 22, rows: 9, height: 13 };
7619
7986
  var MIN = { cols: 25, rows: 16 };
7620
7987
  var TRY_ID = 2 ** 31;
7621
7988
  var BELOW_BG = -1073741826;
@@ -7652,14 +8019,14 @@ function decodeKeys(input) {
7652
8019
  if (m) {
7653
8020
  keys.push(CSI[`${m[1] ?? ""}${m[2] ?? m[3] ?? ""}`] ?? "nop");
7654
8021
  i += 1 + m[0].length;
7655
- } else {
7656
- keys.push("esc");
7657
- i++;
8022
+ continue;
7658
8023
  }
8024
+ keys.push("esc");
8025
+ i++;
7659
8026
  continue;
7660
8027
  }
7661
8028
  keys.push(c === "\r" || c === `
7662
- ` ? "enter" : c === "\t" ? "tab" : c === "\x03" ? "ctrl-c" : c);
8029
+ ` ? "enter" : c === "\t" ? "tab" : c === "\x03" ? "ctrl-c" : c === "" || c === "\b" ? "backspace" : c);
7663
8030
  i++;
7664
8031
  }
7665
8032
  return keys;
@@ -7730,8 +8097,14 @@ function progress(got, size) {
7730
8097
  }
7731
8098
  return size >= 1e6 ? `${(got / 1e6).toFixed(1)}/${(size / 1e6).toFixed(1)} MB` : `${Math.round(got / 1000)}/${Math.round(size / 1000)} KB`;
7732
8099
  }
8100
+ function plain(artist) {
8101
+ return artist.replace(/_\([^)]*\)?$/, "");
8102
+ }
7733
8103
  function dims(tile) {
7734
- return [`${tile.width}×${tile.height}`, Math.max(tile.width, tile.height) < SMALL ? YELLOW : D];
8104
+ return [
8105
+ `${tile.reduced ? "↓" : ""}${tile.width}×${tile.height}`,
8106
+ Math.max(tile.width, tile.height) < SMALL ? YELLOW : D
8107
+ ];
7735
8108
  }
7736
8109
  function foot(line, cols, accent, spec) {
7737
8110
  const keys = [...spec.keys ?? []];
@@ -7854,23 +8227,61 @@ function frameBox(lines, r0, c0, h, w, sgr) {
7854
8227
  }
7855
8228
  lines[r0 + h - 1]?.put(c0, `╰${"─".repeat(w - 2)}╯`, sgr);
7856
8229
  }
7857
- function badge(view) {
7858
- return [` ${view.site} `, `\x1B[7;${30 + view.siteAnsi}m`];
8230
+ function badge(view, label = view.site) {
8231
+ return [` ${label} `, `\x1B[7;${30 + view.siteAnsi}m`];
8232
+ }
8233
+ function tabs(line, cols, view) {
8234
+ const strip = SITES.flatMap((site, i) => {
8235
+ const label = `${site.name}${site.moved ? "*" : ""}`;
8236
+ const tab = site.name === view.site ? badge(view, label) : [` ${label} `, D];
8237
+ return i ? [[" ", ""], tab] : [tab];
8238
+ });
8239
+ const fits = strip.reduce((n, [text]) => n + width(text), 0) <= cols;
8240
+ line.run(0, fits ? strip : [badge(view)]);
7859
8241
  }
7860
8242
  function query(line, cols, view, accent) {
8243
+ if (view.editing !== undefined) {
8244
+ const c2 = line.run(0, [
8245
+ ["⌕ ", accent],
8246
+ [view.editing, ""],
8247
+ ["█", accent]
8248
+ ]);
8249
+ line.put(c2, view.editing ? " enter searches" : ` a tag, a post url or an id — ${view.tag || "esc leaves"}`, D);
8250
+ return;
8251
+ }
7861
8252
  const c = line.run(0, [
7862
8253
  ["⌕ ", accent],
7863
- [view.tag, ""]
8254
+ [view.tag || "nothing yet — / searches", view.tag ? "" : D]
7864
8255
  ]);
7865
- line.put(c, ` ${view.preset}`, D);
8256
+ const allowed = BLOCKS.filter((block2) => !view.block.includes(block2));
8257
+ const state = [
8258
+ view.preset,
8259
+ view.order,
8260
+ ...view.rating.join("+") === "safe" ? [] : [view.rating.join("+")],
8261
+ ...allowed.length > 0 ? [`allows ${allowed.join("+")}`] : [],
8262
+ ...view.unblocked ? ["unblock"] : []
8263
+ ];
8264
+ line.put(c, ` ${state.join(" ")}`, D);
7866
8265
  if (view.total > 0 || !view.searching) {
7867
- const counter = [badge(view), [` ${view.tiles.length}/${view.checked}`, ""]];
8266
+ const counter = [[`${view.tiles.length}/${view.checked}`, ""]];
7868
8267
  if (view.checked < view.total) {
7869
8268
  counter.push([` of ${view.total}`, D]);
7870
8269
  }
7871
8270
  line.right(cols, counter);
7872
8271
  }
7873
8272
  }
8273
+ function transparent(shown) {
8274
+ if (shown.cut === "on") {
8275
+ return [[`cut out · ${shown.clear}% transparent`, GREEN]];
8276
+ }
8277
+ if (shown.cut === "off") {
8278
+ return [["opaque · x cuts out", YELLOW]];
8279
+ }
8280
+ if (shown.cut === "failed") {
8281
+ return [["opaque · no cut-out", YELLOW]];
8282
+ }
8283
+ return [shown.clear > 0 ? [`${shown.clear}% transparent`, GREEN] : ["opaque", YELLOW]];
8284
+ }
7874
8285
  function status(view) {
7875
8286
  if (view.installing !== undefined) {
7876
8287
  return [`installing ${view.palette} ← ${view.site} ${view.installing}`, YELLOW];
@@ -7878,18 +8289,27 @@ function status(view) {
7878
8289
  if (view.error) {
7879
8290
  return [view.error, YELLOW];
7880
8291
  }
8292
+ if (view.waiting !== undefined) {
8293
+ return [`${view.site} asked to slow down · ${view.waiting}s`, YELLOW];
8294
+ }
7881
8295
  if (view.fetching) {
7882
8296
  return [`fetching ${view.fetching.id} · ${progress(view.fetching.got, view.fetching.size)}`, YELLOW];
7883
8297
  }
8298
+ if (view.cutting !== undefined) {
8299
+ return [`cutting out ${view.cutting}`, YELLOW];
8300
+ }
7884
8301
  if (view.preparing !== undefined) {
7885
8302
  return [`preparing ${view.preparing}`, YELLOW];
7886
8303
  }
8304
+ if (view.note) {
8305
+ return [view.note, D];
8306
+ }
7887
8307
  return;
7888
8308
  }
7889
8309
  function grid(lines, images, cols, rows, view, accent) {
7890
8310
  const { perRow, rowsVis } = gridShape(cols, rows);
7891
8311
  query(lines[0], cols, view, accent);
7892
- lines[1]?.put(0, "─".repeat(Math.min(48, cols)), D);
8312
+ tabs(lines[1], cols, view);
7893
8313
  for (let k = 0;k < perRow * rowsVis; k++) {
7894
8314
  const i = view.top * perRow + k;
7895
8315
  const tile = view.tiles[i];
@@ -7909,6 +8329,20 @@ function grid(lines, images, cols, rows, view, accent) {
7909
8329
  if (tile.mates.length > 0) {
7910
8330
  lines[r0 + 10]?.put(c0 + TILE.cols, "≈", accent);
7911
8331
  }
8332
+ const credit = tile.artist ? plain(tile.artist) : tile.owner && `@${tile.owner}`;
8333
+ if (credit) {
8334
+ lines[r0 + 11]?.put(c0 + 1, credit.slice(0, TILE.cols - 8), D);
8335
+ }
8336
+ const marks = [];
8337
+ if (tile.score > 0) {
8338
+ marks.push([`★${tile.score}`, D]);
8339
+ }
8340
+ if (tile.variants > 1) {
8341
+ marks.push([` ×${tile.variants}`, accent]);
8342
+ }
8343
+ if (marks.length > 0) {
8344
+ lines[r0 + 11]?.right(c0 + TILE.cols + 1, marks);
8345
+ }
7912
8346
  }
7913
8347
  const above = view.top * perRow;
7914
8348
  const below = view.tiles.length - (view.top + rowsVis) * perRow;
@@ -7917,7 +8351,7 @@ function grid(lines, images, cols, rows, view, accent) {
7917
8351
  lines[rows - 2]?.put(0, scroll.join(" "), D);
7918
8352
  }
7919
8353
  if (!view.searching && view.tiles.length === 0 && !view.error) {
7920
- lines[3]?.put(0, view.preset === "cutouts" ? `no transparent cutouts of ${view.tag} on ${view.site} — tab searches every post, p tries ${view.nextSite}` : `no posts of ${view.tag} on ${view.site} — p tries ${view.nextSite}`, D);
8354
+ lines[3]?.put(0, view.preset === "cutouts" ? `no transparent cutouts of ${view.tag} on ${view.site} — c searches every post, tab tries ${view.nextSite}` : `no posts of ${view.tag} on ${view.site} — tab tries ${view.nextSite}`, D);
7921
8355
  }
7922
8356
  const line = lines[rows - 1];
7923
8357
  const lead = status(view);
@@ -7935,8 +8369,9 @@ function grid(lines, images, cols, rows, view, accent) {
7935
8369
  keys: [
7936
8370
  ["←↑↓→", "move"],
7937
8371
  ["enter", "try on"],
7938
- ["tab", view.preset === "cutouts" ? "all" : "cutouts"],
7939
- ["p", view.nextSite],
8372
+ ["tab", "site"],
8373
+ ["s", "settings"],
8374
+ ["/", "search"],
7940
8375
  ["?", "keys"]
7941
8376
  ],
7942
8377
  right: ["esc", "back"]
@@ -7949,6 +8384,12 @@ function trial(lines, images, cols, rows, view, accent) {
7949
8384
  }
7950
8385
  const shown = view.shown?.id === tile.id ? view.shown : undefined;
7951
8386
  const meta2 = [badge(view), [" ", ""], [String(tile.id), B], [" ", ""], dims(tile)];
8387
+ if (tile.artist) {
8388
+ meta2.push([` · ${plain(tile.artist)}`, ""]);
8389
+ }
8390
+ if (tile.score > 0) {
8391
+ meta2.push([` · ★${tile.score}`, D]);
8392
+ }
7952
8393
  if (shown) {
7953
8394
  meta2.push([` · ${megabytes(shown.bytes)}`, D]);
7954
8395
  }
@@ -7957,7 +8398,7 @@ function trial(lines, images, cols, rows, view, accent) {
7957
8398
  meta2.push([` · ${tile.origin}`, D]);
7958
8399
  }
7959
8400
  if (shown) {
7960
- meta2.push([" ", ""], shown.clear > 0 ? [`${shown.clear}% transparent`, GREEN] : ["opaque", YELLOW]);
8401
+ meta2.push([" ", ""], ...transparent(shown));
7961
8402
  }
7962
8403
  lines[0]?.run(0, meta2);
7963
8404
  if (tile.mates.length > 0) {
@@ -7977,6 +8418,7 @@ function trial(lines, images, cols, rows, view, accent) {
7977
8418
  keys: view.installing === undefined ? [
7978
8419
  ["←→", "browse"],
7979
8420
  ["enter", "install"],
8421
+ ...view.shown?.cut === "on" || view.shown?.cut === "off" ? [["x", "cut out"]] : [],
7980
8422
  ["?", "keys"]
7981
8423
  ] : [],
7982
8424
  right: view.installing === undefined ? ["esc", "grid"] : undefined
@@ -7986,18 +8428,60 @@ var KEYS = {
7986
8428
  grid: [
7987
8429
  ["move", "←↑↓→ home end pgup pgdn"],
7988
8430
  ["try on", "enter"],
7989
- ["posts", "tab cutouts or every post"],
7990
- ["site", `p ${SITES.map((site) => site.name).join(", ")}`],
8431
+ ["site", `tab ${SITES.map((site) => site.name).join(", ")}`],
8432
+ ["posts", "c cutouts or every post"],
8433
+ ["search", "/ a tag, a post url or an id"],
8434
+ ["unfold", "space a set of ×N"],
8435
+ ["open", "o the post page in a browser"],
8436
+ ["settings", "s rating, block, posts, order, sets, remove bg"],
7991
8437
  ["back", "esc returns to preview"],
7992
8438
  ["close", "? esc"]
7993
8439
  ],
7994
8440
  try: [
7995
8441
  ["browse", "←→"],
7996
8442
  ["install", "enter"],
8443
+ ["cut out", "x the background off or on, on an opaque picture"],
8444
+ ["open", "o the post page in a browser"],
7997
8445
  ["grid", "esc"],
7998
8446
  ["close", "? esc"]
7999
8447
  ]
8000
8448
  };
8449
+ function panel(lines, cols, rows, view, accent) {
8450
+ const at2 = view.panel ?? 0;
8451
+ const label = Math.max(...view.settings.map((row) => row.label.length));
8452
+ const widest = Math.max(...view.settings.map((row) => row.choices.reduce((n, c) => n + c.length + (row.multi ? 6 : 3), 0)));
8453
+ const w = Math.min(cols - 2, Math.max(28, label + widest + 8));
8454
+ const h = view.settings.length + 4;
8455
+ const x = Math.floor((cols - w) / 2);
8456
+ const y = Math.max(2, Math.floor((rows - h) / 2));
8457
+ lines[y]?.put(x, `╭─ settings ${"─".repeat(Math.max(0, w - 13))}╮`);
8458
+ for (let r = y + 1;r < y + h - 1; r++) {
8459
+ lines[r]?.put(x, `│${" ".repeat(w - 2)}│`);
8460
+ }
8461
+ view.settings.forEach((row, i) => {
8462
+ const line = lines[y + 2 + i];
8463
+ line.put(x + 3, row.label, i === at2 ? B : D);
8464
+ let c = x + 5 + label;
8465
+ const on = row.value.split(" ");
8466
+ row.choices.forEach((choice, k) => {
8467
+ const lit = on.includes(choice);
8468
+ const under = row.multi && i === at2 && k === row.cursor ? "4;" : "";
8469
+ const text = row.multi ? ` ${lit ? "[x]" : "[ ]"} ${choice} ` : ` ${choice} `;
8470
+ c = line.put(c, text, lit ? `\x1B[${under}7;${30 + view.siteAnsi}m` : under ? `\x1B[4m${D}` : D) + 1;
8471
+ });
8472
+ });
8473
+ lines[y + h - 1]?.put(x, `╰${"─".repeat(w - 2)}╯`);
8474
+ foot(lines[rows - 1], cols, accent, {
8475
+ badge: "SET",
8476
+ keys: [
8477
+ ["↑↓", "setting"],
8478
+ ["←→", "value"],
8479
+ ["space", "toggle"],
8480
+ ["enter", "save"]
8481
+ ],
8482
+ right: ["esc", "undo"]
8483
+ });
8484
+ }
8001
8485
  function help(lines, cols, rows, view, accent) {
8002
8486
  const keys = KEYS[view.mode];
8003
8487
  const w = Math.min(cols - 2, 50);
@@ -8030,19 +8514,23 @@ function renderFind(view, cols, rows) {
8030
8514
  } else {
8031
8515
  grid(lines, images, cols, rows, view, accent);
8032
8516
  }
8033
- if (view.help) {
8517
+ if (view.help || view.panel !== undefined) {
8034
8518
  const keep = view.mode === "try" ? images : [];
8035
8519
  for (let r = 0;r < rows; r++) {
8036
8520
  lines[r] = new Line(cols);
8037
8521
  }
8038
- help(lines, cols, rows, view, accent);
8522
+ if (view.help) {
8523
+ help(lines, cols, rows, view, accent);
8524
+ } else {
8525
+ panel(lines, cols, rows, view, accent);
8526
+ }
8039
8527
  return { lines: lines.map((l) => l.render()), images: keep };
8040
8528
  }
8041
8529
  return { lines: lines.map((l) => l.render()), images };
8042
8530
  }
8043
8531
 
8044
8532
  // src/palettes.ts
8045
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
8533
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
8046
8534
  import { homedir as homedir2 } from "node:os";
8047
8535
  import { dirname as dirname2, join as join6 } from "node:path";
8048
8536
  var EMITTERS = { ghostty, kitty, alacritty };
@@ -8057,7 +8545,7 @@ function readInstalled(configHome2) {
8057
8545
  if (!existsSync2(path2)) {
8058
8546
  throw new Error(`nothing installed yet at ${path2} — run \`ttheme init\` first`);
8059
8547
  }
8060
- const doc = JSON.parse(readFileSync3(path2, "utf8"));
8548
+ const doc = JSON.parse(readFileSync4(path2, "utf8"));
8061
8549
  if (!Array.isArray(doc?.palettes) || !Array.isArray(doc.terminals)) {
8062
8550
  throw new Error(`${path2} has no palettes or terminals`);
8063
8551
  }
@@ -8152,7 +8640,7 @@ function sync(configHome2, catalog, state) {
8152
8640
  }
8153
8641
  }
8154
8642
  const { file, body } = blockFor(terminal, configHome2, startup);
8155
- const current = existsSync2(file) ? readFileSync3(file, "utf8") : "";
8643
+ const current = existsSync2(file) ? readFileSync4(file, "utf8") : "";
8156
8644
  if (terminal === "alacritty" && current !== "" && !current.includes("# ttheme begin")) {
8157
8645
  continue;
8158
8646
  }
@@ -8179,11 +8667,160 @@ function forget(configHome2, catalog, terminals, names) {
8179
8667
  return removed;
8180
8668
  }
8181
8669
 
8670
+ // src/unblock.ts
8671
+ import { connect, createServer } from "node:net";
8672
+ var SPLIT = 20;
8673
+ var HANDSHAKE = 22;
8674
+ function joined(parts) {
8675
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0));
8676
+ let at2 = 0;
8677
+ for (const part of parts) {
8678
+ out.set(part, at2);
8679
+ at2 += part.length;
8680
+ }
8681
+ return out;
8682
+ }
8683
+ function reframe(record, at2 = SPLIT) {
8684
+ const body = record.subarray(5);
8685
+ if (record[0] !== HANDSHAKE || body.length < 2) {
8686
+ return [record];
8687
+ }
8688
+ const cut = Math.max(1, Math.min(at2, body.length - 1));
8689
+ const framed = (part) => joined([
8690
+ Uint8Array.from([
8691
+ record[0],
8692
+ record[1],
8693
+ record[2],
8694
+ part.length >> 8,
8695
+ part.length & 255
8696
+ ]),
8697
+ part
8698
+ ]);
8699
+ return [framed(body.subarray(0, cut)), framed(body.subarray(cut))];
8700
+ }
8701
+ function tunnelTo(client, host, port, rest) {
8702
+ const upstream = connect(port, host, () => {
8703
+ upstream.setNoDelay(true);
8704
+ client.write(`HTTP/1.1 200 Connection Established\r
8705
+ \r
8706
+ `);
8707
+ let held = rest;
8708
+ let framed = false;
8709
+ const forward = (chunk) => {
8710
+ if (framed) {
8711
+ upstream.write(chunk);
8712
+ return;
8713
+ }
8714
+ held = joined([held, chunk]);
8715
+ if (held.length < 5) {
8716
+ return;
8717
+ }
8718
+ const length = held[3] * 256 + held[4];
8719
+ if (held[0] !== HANDSHAKE) {
8720
+ framed = true;
8721
+ upstream.write(held);
8722
+ return;
8723
+ }
8724
+ if (held.length < 5 + length) {
8725
+ return;
8726
+ }
8727
+ framed = true;
8728
+ const [first, second] = reframe(held.subarray(0, 5 + length));
8729
+ upstream.write(first);
8730
+ setTimeout(() => {
8731
+ if (second) {
8732
+ upstream.write(second);
8733
+ }
8734
+ upstream.write(held.subarray(5 + length));
8735
+ }, 10);
8736
+ };
8737
+ client.on("data", forward);
8738
+ if (held.length > 0) {
8739
+ const pending = held;
8740
+ held = new Uint8Array(0);
8741
+ forward(pending);
8742
+ }
8743
+ upstream.pipe(client);
8744
+ });
8745
+ upstream.on("error", () => client.destroy());
8746
+ client.on("error", () => upstream.destroy());
8747
+ }
8748
+ function serve(client) {
8749
+ client.setNoDelay(true);
8750
+ let head = new Uint8Array(0);
8751
+ const onData = (chunk) => {
8752
+ head = joined([head, chunk]);
8753
+ const text = Buffer.from(head).toString("latin1");
8754
+ const end = text.indexOf(`\r
8755
+ \r
8756
+ `);
8757
+ if (end === -1) {
8758
+ return;
8759
+ }
8760
+ client.off("data", onData);
8761
+ const target = /^CONNECT ([^ :]+):(\d+)/.exec(text);
8762
+ if (!target) {
8763
+ client.end(`HTTP/1.1 405 Method Not Allowed\r
8764
+ \r
8765
+ `);
8766
+ return;
8767
+ }
8768
+ tunnelTo(client, target[1], Number(target[2]), head.subarray(end + 4));
8769
+ };
8770
+ client.on("data", onData);
8771
+ client.on("error", () => client.destroy());
8772
+ }
8773
+ function tunnel() {
8774
+ const server = createServer(serve);
8775
+ return new Promise((resolve2, reject) => {
8776
+ server.once("error", reject);
8777
+ server.listen(0, "127.0.0.1", () => {
8778
+ resolve2({
8779
+ port: server.address().port,
8780
+ close: () => {
8781
+ server.close();
8782
+ server.unref();
8783
+ }
8784
+ });
8785
+ });
8786
+ });
8787
+ }
8788
+
8182
8789
  // src/find.ts
8790
+ var SETTINGS = [
8791
+ { name: "TTHEME_FIND_RATING", label: "rating", choices: RATINGS, multi: { read: ratingSet } },
8792
+ { name: "TTHEME_FIND_BLOCK", label: "block", choices: BLOCKS, multi: { read: blockSet, none: "none" } },
8793
+ { name: "TTHEME_FIND_POSTS", label: "posts", choices: ["all", "cutouts"] },
8794
+ { name: "TTHEME_FIND_ORDER", label: "order", choices: ["newest", "score"] },
8795
+ { name: "TTHEME_FIND_SETS", label: "sets", choices: ["fold", "show"] },
8796
+ ...canRemoveBackground() ? [{ name: "TTHEME_FIND_REMOVE_BG", label: "remove bg", choices: ["on", "off"] }] : []
8797
+ ];
8798
+ function initial(setting, raw) {
8799
+ if (setting.multi) {
8800
+ return setting.multi.read(raw).join(" ") || setting.multi.none;
8801
+ }
8802
+ return setting.choices.find((choice) => choice === raw) ?? setting.choices[0];
8803
+ }
8183
8804
  var WORKERS = 4;
8805
+ var PRELOAD = 2;
8184
8806
  var SETTLE = 150;
8807
+ var ESCAPE = 30;
8185
8808
  var TRY_WIDTH = 1280;
8186
8809
  var LOOKAHEAD = 1;
8810
+ function blank(key, searching) {
8811
+ return {
8812
+ key,
8813
+ groups: [],
8814
+ sources: [],
8815
+ queue: [],
8816
+ seen: new Set,
8817
+ checked: 0,
8818
+ total: 0,
8819
+ focus: 0,
8820
+ top: 0,
8821
+ searching
8822
+ };
8823
+ }
8187
8824
  function describe(error) {
8188
8825
  if (!(error instanceof Error)) {
8189
8826
  return String(error);
@@ -8195,14 +8832,34 @@ function describe(error) {
8195
8832
  return describe(error.cause);
8196
8833
  }
8197
8834
  const code = error.code;
8198
- return typeof code === "string" ? code : error.message || error.name;
8835
+ if (typeof code === "string") {
8836
+ return code;
8837
+ }
8838
+ const first = (error.message || error.name).split(".")[0] ?? "";
8839
+ return first.length > 56 ? `${first.slice(0, 55)}…` : first;
8199
8840
  }
8200
8841
  function reason(site, error) {
8201
8842
  return `${site.name}: ${describe(error)}`;
8202
8843
  }
8844
+ function incomplete(input) {
8845
+ const at2 = input.lastIndexOf("\x1B");
8846
+ return at2 !== -1 && /^\[?[0-9;]*$/.test(input.slice(at2 + 1));
8847
+ }
8203
8848
  function tick() {
8204
8849
  return new Promise((resolve2) => setImmediate(resolve2));
8205
8850
  }
8851
+ function readCache(site, name) {
8852
+ try {
8853
+ return JSON.parse(readFileSync5(join7(cacheDir(site), name), "utf8"));
8854
+ } catch {
8855
+ return {};
8856
+ }
8857
+ }
8858
+ function writeCache(site, name, data) {
8859
+ mkdirSync4(cacheDir(site), { recursive: true });
8860
+ writeFileSync4(join7(cacheDir(site), name), `${JSON.stringify(data)}
8861
+ `);
8862
+ }
8206
8863
 
8207
8864
  class Finder {
8208
8865
  view;
@@ -8213,12 +8870,18 @@ class Finder {
8213
8870
  rows = process.stdout.rows || 24;
8214
8871
  gen = 0;
8215
8872
  pumping = 0;
8216
- sources = [];
8217
- queue = [];
8218
- seen = new Set;
8873
+ boards = new Map;
8874
+ board;
8219
8875
  posts = new Map;
8876
+ thumbPath = new Map;
8220
8877
  siteIndex = 0;
8878
+ values = new Map(SETTINGS.map((setting) => [setting.name, initial(setting, process.env[setting.name])]));
8879
+ direction = 1;
8880
+ prefetching = 0;
8881
+ inflight = new Map;
8221
8882
  owners = new Map;
8883
+ probed = new Map;
8884
+ unsaved = new Set;
8222
8885
  thumbQueue = [];
8223
8886
  thumbing = 0;
8224
8887
  sent = new Map;
@@ -8226,6 +8889,7 @@ class Finder {
8226
8889
  current;
8227
8890
  fetch;
8228
8891
  settle;
8892
+ partial;
8229
8893
  input = "";
8230
8894
  probeWait;
8231
8895
  done;
@@ -8245,17 +8909,32 @@ class Finder {
8245
8909
  site: this.site.name,
8246
8910
  siteAnsi: this.site.ansi,
8247
8911
  nextSite: this.nextSite.name,
8248
- preset: "cutouts",
8912
+ preset: this.setting("TTHEME_FIND_POSTS") === "all" ? "all" : "cutouts",
8913
+ order: this.setting("TTHEME_FIND_ORDER") === "score" ? "score" : "newest",
8914
+ rating: ratingSet(this.setting("TTHEME_FIND_RATING")),
8915
+ block: blockSet(this.setting("TTHEME_FIND_BLOCK")),
8916
+ sets: this.setting("TTHEME_FIND_SETS") === "show" ? "show" : "fold",
8917
+ unblocked: process.env.TTHEME_FIND_PROXY !== undefined,
8918
+ settings: SETTINGS.map((setting) => ({
8919
+ label: setting.label,
8920
+ choices: [...setting.choices],
8921
+ value: this.setting(setting.name),
8922
+ multi: setting.multi && { none: setting.multi.none },
8923
+ cursor: 0
8924
+ })),
8249
8925
  colors: { cursor: entry.cursor, selection: entry.selection, ansi: entry.ansi },
8250
8926
  tiles: [],
8251
8927
  checked: 0,
8252
8928
  total: 0,
8253
- searching: true,
8929
+ searching: tag !== "",
8254
8930
  focus: 0,
8255
8931
  top: 0,
8256
8932
  mode: "grid",
8257
- help: false
8933
+ help: false,
8934
+ editing: tag === "" ? "" : undefined
8258
8935
  };
8936
+ this.board = blank(this.boardKey, tag !== "");
8937
+ this.boards.set(this.board.key, this.board);
8259
8938
  }
8260
8939
  get signal() {
8261
8940
  return this.session.signal;
@@ -8266,6 +8945,52 @@ class Finder {
8266
8945
  get nextSite() {
8267
8946
  return SITES[(this.siteIndex + 1) % SITES.length];
8268
8947
  }
8948
+ setting(name) {
8949
+ return this.values.get(name);
8950
+ }
8951
+ get boardKey() {
8952
+ return `${this.site.key}|${this.view.preset}|${this.view.order}`;
8953
+ }
8954
+ mark(site, id) {
8955
+ return `${site.key}:${id}`;
8956
+ }
8957
+ origPath(site, id, ext) {
8958
+ return join7(this.scratch, "orig", `${site.key}-${id}.${ext}`);
8959
+ }
8960
+ tileOf(site, post2, variants) {
8961
+ const version2 = rendition(post2) ?? post2;
8962
+ return {
8963
+ id: post2.id,
8964
+ width: version2.width,
8965
+ height: version2.height,
8966
+ reduced: version2 !== post2,
8967
+ owner: mirrored(post2.owner) ? "" : post2.owner,
8968
+ artist: post2.artist,
8969
+ score: post2.score,
8970
+ variants,
8971
+ origin: originHost(post2.source),
8972
+ mates: this.owners.get(site.key)?.get(post2.owner) ?? [],
8973
+ thumb: this.thumbPath.get(this.mark(site, post2.id))
8974
+ };
8975
+ }
8976
+ show() {
8977
+ const site = this.site;
8978
+ const view = this.view;
8979
+ const board = this.board;
8980
+ view.tiles = board.groups.flatMap((group) => group.open ? group.posts.map((post2) => this.tileOf(site, post2, 0)) : [this.tileOf(site, group.posts[0], group.posts.length)]);
8981
+ view.checked = board.checked;
8982
+ view.total = board.total;
8983
+ view.searching = board.searching;
8984
+ view.note = board.note;
8985
+ view.focus = Math.max(0, Math.min(board.focus, view.tiles.length - 1));
8986
+ view.top = board.top;
8987
+ }
8988
+ syncSite() {
8989
+ const view = this.view;
8990
+ view.site = this.site.name;
8991
+ view.siteAnsi = this.site.ansi;
8992
+ view.nextSite = this.nextSite.name;
8993
+ }
8269
8994
  async run() {
8270
8995
  const { stdin, stdout } = process;
8271
8996
  stdin.setRawMode(true);
@@ -8276,10 +9001,13 @@ class Finder {
8276
9001
  const exit = new Promise((resolve2) => {
8277
9002
  this.done = resolve2;
8278
9003
  });
9004
+ const clock = setInterval(this.onClock, 250);
8279
9005
  const cell = await this.probe();
8280
9006
  if (cell) {
8281
9007
  this.cell = cell;
8282
- this.search();
9008
+ if (this.view.tag) {
9009
+ this.search();
9010
+ }
8283
9011
  } else {
8284
9012
  this.view.searching = false;
8285
9013
  this.view.error = "this terminal does not report its cell size — find needs kitty graphics";
@@ -8289,6 +9017,8 @@ class Finder {
8289
9017
  this.session.abort();
8290
9018
  this.fetch?.abort();
8291
9019
  clearTimeout(this.settle);
9020
+ clearTimeout(this.partial);
9021
+ clearInterval(clock);
8292
9022
  stdin.off("data", this.onData);
8293
9023
  stdout.off("resize", this.onResize);
8294
9024
  this.write("\x1B_Ga=d,d=A,q=2\x1B\\\x1B[H\x1B[J");
@@ -8328,12 +9058,28 @@ class Finder {
8328
9058
  this.input = this.input.slice(0, at2) + this.input.slice(at2 + 1 + m[0].length);
8329
9059
  this.probeWait({ h: Number(m[1]), w: Number(m[2]) });
8330
9060
  }
9061
+ clearTimeout(this.partial);
9062
+ if (incomplete(this.input)) {
9063
+ this.partial = setTimeout(this.flushKeys, ESCAPE);
9064
+ return;
9065
+ }
9066
+ this.flushKeys();
9067
+ };
9068
+ flushKeys = () => {
8331
9069
  const keys = decodeKeys(this.input);
8332
9070
  this.input = "";
8333
9071
  for (const key of keys) {
8334
9072
  this.key(key);
8335
9073
  }
8336
9074
  };
9075
+ onClock = () => {
9076
+ const left = Math.ceil((pausedUntil(this.site) - Date.now()) / 1000);
9077
+ const waiting = left > 0 ? left : undefined;
9078
+ if (waiting !== this.view.waiting) {
9079
+ this.view.waiting = waiting;
9080
+ this.draw();
9081
+ }
9082
+ };
8337
9083
  onResize = () => {
8338
9084
  this.cols = process.stdout.columns || this.cols;
8339
9085
  this.rows = process.stdout.rows || this.rows;
@@ -8353,6 +9099,14 @@ class Finder {
8353
9099
  if (view.installing !== undefined) {
8354
9100
  return;
8355
9101
  }
9102
+ if (view.editing !== undefined) {
9103
+ this.editKey(key);
9104
+ return;
9105
+ }
9106
+ if (view.panel !== undefined) {
9107
+ this.panelKey(key);
9108
+ return;
9109
+ }
8356
9110
  if (view.help) {
8357
9111
  if (key === "?" || key === "esc") {
8358
9112
  view.help = false;
@@ -8401,29 +9155,265 @@ class Finder {
8401
9155
  this.select();
8402
9156
  return;
8403
9157
  }
8404
- if (key === "tab") {
9158
+ if (key === "c") {
8405
9159
  view.preset = view.preset === "cutouts" ? "all" : "cutouts";
8406
- this.search();
9160
+ this.turn();
8407
9161
  return;
8408
9162
  }
8409
- if (key === "p") {
9163
+ if (key === "tab") {
8410
9164
  this.siteIndex = (this.siteIndex + 1) % SITES.length;
8411
- this.current = undefined;
8412
- view.shown = undefined;
8413
- view.site = this.site.name;
8414
- view.siteAnsi = this.site.ansi;
8415
- view.nextSite = this.nextSite.name;
8416
- this.search();
9165
+ this.syncSite();
9166
+ this.turn();
9167
+ return;
9168
+ }
9169
+ if (key === "/") {
9170
+ view.editing = "";
9171
+ this.draw();
9172
+ return;
9173
+ }
9174
+ if (key === " ") {
9175
+ this.unfold();
9176
+ return;
9177
+ }
9178
+ if (key === "o") {
9179
+ this.openPage();
9180
+ return;
9181
+ }
9182
+ if (key === "s") {
9183
+ view.panel = 0;
9184
+ this.draw();
8417
9185
  return;
8418
9186
  }
8419
9187
  if (key === "esc") {
8420
9188
  this.finish(2);
8421
9189
  }
8422
9190
  }
9191
+ panelKey(key) {
9192
+ const view = this.view;
9193
+ const at2 = view.panel ?? 0;
9194
+ const row = view.settings[at2];
9195
+ if (key === "up" || key === "down") {
9196
+ view.panel = Math.max(0, Math.min(view.settings.length - 1, at2 + (key === "up" ? -1 : 1)));
9197
+ this.draw();
9198
+ return;
9199
+ }
9200
+ if ((key === "left" || key === "right") && row) {
9201
+ const step = key === "left" ? -1 : 1;
9202
+ if (row.multi) {
9203
+ row.cursor = (row.cursor + step + row.choices.length) % row.choices.length;
9204
+ } else {
9205
+ const index = row.choices.indexOf(row.value);
9206
+ row.value = row.choices[(index + step + row.choices.length) % row.choices.length];
9207
+ }
9208
+ this.draw();
9209
+ return;
9210
+ }
9211
+ if (key === " " && row?.multi) {
9212
+ const on = row.value.split(" ");
9213
+ const next = row.choices.filter((choice, k) => k === row.cursor ? !on.includes(choice) : on.includes(choice));
9214
+ const empty = row.multi.none;
9215
+ if (next.length > 0 || empty !== undefined) {
9216
+ row.value = next.length > 0 ? next.join(" ") : empty;
9217
+ this.draw();
9218
+ }
9219
+ return;
9220
+ }
9221
+ if (key === "esc") {
9222
+ view.settings.forEach((setting, i) => {
9223
+ setting.value = this.setting(SETTINGS[i]?.name ?? "");
9224
+ });
9225
+ view.panel = undefined;
9226
+ this.draw();
9227
+ return;
9228
+ }
9229
+ if (key === "enter" || key === "alt-c") {
9230
+ view.panel = undefined;
9231
+ this.adopt();
9232
+ }
9233
+ }
9234
+ adopt() {
9235
+ const view = this.view;
9236
+ const before = SETTINGS.map((setting) => this.setting(setting.name));
9237
+ view.settings.forEach((row, i) => {
9238
+ const setting = SETTINGS[i];
9239
+ if (setting) {
9240
+ this.values.set(setting.name, row.value);
9241
+ }
9242
+ });
9243
+ const changed = SETTINGS.filter((setting, i) => this.setting(setting.name) !== before[i]);
9244
+ if (changed.length === 0) {
9245
+ this.draw();
9246
+ return;
9247
+ }
9248
+ this.save(changed);
9249
+ view.rating = ratingSet(this.setting("TTHEME_FIND_RATING"));
9250
+ view.block = blockSet(this.setting("TTHEME_FIND_BLOCK"));
9251
+ view.sets = this.setting("TTHEME_FIND_SETS") === "show" ? "show" : "fold";
9252
+ view.preset = this.setting("TTHEME_FIND_POSTS") === "all" ? "all" : "cutouts";
9253
+ view.order = this.setting("TTHEME_FIND_ORDER") === "score" ? "score" : "newest";
9254
+ this.boards.clear();
9255
+ this.current = undefined;
9256
+ view.shown = undefined;
9257
+ view.mode = "grid";
9258
+ this.search();
9259
+ }
9260
+ save(changed) {
9261
+ const path2 = join7(this.home, "ttheme", "config.zsh");
9262
+ try {
9263
+ const before = existsSync3(path2) ? readFileSync5(path2, "utf8") : "";
9264
+ const after = changed.reduce((text, setting) => withSetting(text, setting.name, this.setting(setting.name)), before);
9265
+ writeFileSync4(path2, after);
9266
+ } catch (error) {
9267
+ this.view.error = error instanceof Error ? error.message : String(error);
9268
+ }
9269
+ }
9270
+ editKey(key) {
9271
+ const view = this.view;
9272
+ const text = view.editing ?? "";
9273
+ if (key === "esc") {
9274
+ view.editing = undefined;
9275
+ if (view.tag === "") {
9276
+ this.finish(2);
9277
+ return;
9278
+ }
9279
+ this.draw();
9280
+ return;
9281
+ }
9282
+ if (key === "enter") {
9283
+ view.editing = undefined;
9284
+ if (text.trim()) {
9285
+ this.commit(text.trim());
9286
+ return;
9287
+ }
9288
+ if (view.tag === "") {
9289
+ this.finish(2);
9290
+ return;
9291
+ }
9292
+ this.draw();
9293
+ return;
9294
+ }
9295
+ if (key === "backspace") {
9296
+ view.editing = text.slice(0, -1);
9297
+ this.draw();
9298
+ return;
9299
+ }
9300
+ if (key.length === 1 && key >= " ") {
9301
+ view.editing = text + key;
9302
+ this.draw();
9303
+ }
9304
+ }
9305
+ async commit(text) {
9306
+ const ref = postRef(text, this.site);
9307
+ if (ref) {
9308
+ await this.jump(ref.site, ref.id);
9309
+ return;
9310
+ }
9311
+ this.view.tag = text;
9312
+ this.boards.clear();
9313
+ await this.search();
9314
+ }
9315
+ async jump(site, id) {
9316
+ const gen = ++this.gen;
9317
+ this.siteIndex = SITES.indexOf(site);
9318
+ this.syncSite();
9319
+ this.current = undefined;
9320
+ this.view.shown = undefined;
9321
+ this.view.error = undefined;
9322
+ const board = blank(`${site.key}|post:${id}`, true);
9323
+ this.board = board;
9324
+ this.boards.set(board.key, board);
9325
+ this.show();
9326
+ this.draw();
9327
+ try {
9328
+ const post2 = await fetchPost(site, id, this.signal);
9329
+ if (gen !== this.gen) {
9330
+ return;
9331
+ }
9332
+ board.searching = false;
9333
+ if (!post2) {
9334
+ board.error = `${site.name} has no post ${id}`;
9335
+ this.view.error = board.error;
9336
+ } else {
9337
+ this.posts.set(this.mark(site, post2.id), post2);
9338
+ board.groups = [{ posts: [post2], open: false }];
9339
+ board.checked = 1;
9340
+ board.total = 1;
9341
+ this.thumbQueue.push({ site, post: post2 });
9342
+ this.thumbs();
9343
+ this.view.mode = "try";
9344
+ }
9345
+ this.show();
9346
+ if (post2) {
9347
+ this.select();
9348
+ }
9349
+ this.draw();
9350
+ } catch (error) {
9351
+ this.fail(gen, error);
9352
+ }
9353
+ }
9354
+ async turn() {
9355
+ this.current = undefined;
9356
+ this.view.shown = undefined;
9357
+ const known = this.boards.get(this.boardKey);
9358
+ if (!known) {
9359
+ await this.search();
9360
+ return;
9361
+ }
9362
+ this.gen++;
9363
+ this.board = known;
9364
+ this.view.error = known.error;
9365
+ this.show();
9366
+ this.draw();
9367
+ await this.pump();
9368
+ }
9369
+ unfold() {
9370
+ const view = this.view;
9371
+ let index = 0;
9372
+ for (const group of this.board.groups) {
9373
+ const size = group.open ? group.posts.length : 1;
9374
+ if (view.focus < index + size) {
9375
+ if (group.posts.length < 2) {
9376
+ return;
9377
+ }
9378
+ group.open = !group.open;
9379
+ this.board.focus = index;
9380
+ for (const post2 of group.posts) {
9381
+ if (!this.thumbPath.has(this.mark(this.site, post2.id))) {
9382
+ this.thumbQueue.push({ site: this.site, post: post2 });
9383
+ }
9384
+ }
9385
+ this.thumbs();
9386
+ this.show();
9387
+ this.scroll();
9388
+ this.draw();
9389
+ return;
9390
+ }
9391
+ index += size;
9392
+ }
9393
+ }
9394
+ openPage() {
9395
+ const tile = this.view.tiles[this.view.focus];
9396
+ if (!tile) {
9397
+ return;
9398
+ }
9399
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
9400
+ try {
9401
+ spawn2(opener, [this.site.pageUrl(tile.id)], { stdio: "ignore", detached: true }).unref();
9402
+ } catch {}
9403
+ }
8423
9404
  tryKey(key) {
8424
9405
  const view = this.view;
8425
9406
  if (key === "left" || key === "right") {
8426
- this.focus(view.focus + (key === "left" ? -1 : 1));
9407
+ this.direction = key === "left" ? -1 : 1;
9408
+ this.focus(view.focus + this.direction);
9409
+ return;
9410
+ }
9411
+ if (key === "o") {
9412
+ this.openPage();
9413
+ return;
9414
+ }
9415
+ if (key === "x") {
9416
+ this.swap();
8427
9417
  return;
8428
9418
  }
8429
9419
  if (key === "enter") {
@@ -8449,6 +9439,7 @@ class Finder {
8449
9439
  return;
8450
9440
  }
8451
9441
  view.focus = next;
9442
+ this.board.focus = next;
8452
9443
  this.scroll();
8453
9444
  this.pump();
8454
9445
  if (view.mode === "try") {
@@ -8466,28 +9457,50 @@ class Finder {
8466
9457
  if (row > view.top + rowsVis - 1) {
8467
9458
  view.top = row - rowsVis + 1;
8468
9459
  }
9460
+ this.board.top = view.top;
9461
+ }
9462
+ query(site) {
9463
+ const view = this.view;
9464
+ const wanted = [
9465
+ view.tag,
9466
+ site.rate(this.view.rating),
9467
+ view.preset === "cutouts" ? site.cutouts : "",
9468
+ view.order === "score" ? site.best : ""
9469
+ ].filter(Boolean);
9470
+ const kept = [];
9471
+ const dropped = [];
9472
+ for (const part of wanted) {
9473
+ if (tagsOf([...kept, part].join(" ")) <= site.tagBudget) {
9474
+ kept.push(part);
9475
+ } else {
9476
+ dropped.push(part);
9477
+ }
9478
+ }
9479
+ this.board.note = dropped.length > 0 ? `${site.name} takes ${site.tagBudget} tags — ${dropped.join(" ")} left out` : undefined;
9480
+ return kept.join(" ");
8469
9481
  }
8470
9482
  async search() {
8471
9483
  const gen = ++this.gen;
8472
- const view = this.view;
8473
- Object.assign(view, { tiles: [], checked: 0, total: 0, searching: true, focus: 0, top: 0, error: undefined });
8474
- this.seen.clear();
8475
- this.queue = [];
8476
- this.sources = [];
8477
- this.thumbQueue = [];
8478
- this.draw();
8479
9484
  const site = this.site;
8480
- const tags = view.preset === "cutouts" ? `${view.tag} ${site.cutouts}` : view.tag;
9485
+ const board = blank(this.boardKey, true);
9486
+ this.board = board;
9487
+ this.boards.set(board.key, board);
9488
+ this.view.error = undefined;
9489
+ this.show();
9490
+ this.draw();
9491
+ const tags = this.query(site);
8481
9492
  try {
8482
9493
  const [total, owners] = await Promise.all([fetchCount(site, tags, this.signal), this.mateOwners(site)]);
8483
9494
  if (gen !== this.gen) {
8484
9495
  return;
8485
9496
  }
8486
- view.total = total;
8487
- this.sources = [
8488
- ...[...owners.keys()].map((owner) => ({ tags: `${tags} user:${owner}`, page: 0, done: false })),
9497
+ board.total = total;
9498
+ const room = tagsOf(tags) + 1 <= site.tagBudget;
9499
+ board.sources = [
9500
+ ...room ? [...owners.keys()].map((owner) => ({ tags: `${tags} user:${owner}`, page: 0, done: false })) : [],
8489
9501
  { tags, page: 0, done: false }
8490
9502
  ];
9503
+ this.show();
8491
9504
  this.draw();
8492
9505
  await this.pump();
8493
9506
  } catch (error) {
@@ -8498,62 +9511,64 @@ class Finder {
8498
9511
  if (gen !== this.gen || this.signal.aborted) {
8499
9512
  return;
8500
9513
  }
8501
- this.view.error = reason(this.site, error);
8502
- this.view.searching = false;
9514
+ this.board.error = reason(this.site, error);
9515
+ this.board.searching = false;
9516
+ this.view.error = this.board.error;
9517
+ this.show();
8503
9518
  this.draw();
8504
9519
  }
8505
9520
  wants() {
8506
- const view = this.view;
8507
- if (view.checked < Math.min(view.total, PAGE)) {
8508
- return true;
8509
- }
8510
9521
  const { perRow, rowsVis } = gridShape(this.cols, this.rows);
8511
- return view.tiles.length < (view.top + rowsVis + LOOKAHEAD) * perRow;
9522
+ return this.view.tiles.length < (this.board.top + rowsVis + LOOKAHEAD) * perRow;
8512
9523
  }
8513
9524
  async pump() {
8514
9525
  const gen = this.gen;
8515
- if (this.pumping === gen || this.sources.length === 0) {
9526
+ const board = this.board;
9527
+ if (this.pumping === gen || board.sources.length === 0) {
8516
9528
  return;
8517
9529
  }
8518
9530
  this.pumping = gen;
8519
- const view = this.view;
9531
+ const site = this.site;
8520
9532
  try {
8521
- view.searching = true;
9533
+ board.searching = true;
8522
9534
  while (gen === this.gen && this.wants()) {
8523
- if (this.queue.length === 0) {
8524
- const source = this.sources.find((s) => !s.done);
9535
+ if (board.queue.length === 0) {
9536
+ const source = board.sources.find((s) => !s.done);
8525
9537
  if (!source) {
8526
9538
  break;
8527
9539
  }
8528
- const posts = await fetchPosts(this.site, source.tags, source.page, this.signal);
9540
+ const posts = await fetchPosts(site, source.tags, source.page, this.signal);
8529
9541
  if (gen !== this.gen) {
8530
9542
  return;
8531
9543
  }
8532
9544
  source.page++;
8533
9545
  source.done = posts.length < PAGE;
8534
9546
  for (const post2 of posts) {
8535
- if (!this.seen.has(post2.id)) {
8536
- this.seen.add(post2.id);
8537
- this.queue.push(post2);
9547
+ if (!board.seen.has(post2.id)) {
9548
+ board.seen.add(post2.id);
9549
+ board.queue.push(post2);
8538
9550
  }
8539
9551
  }
8540
9552
  continue;
8541
9553
  }
8542
- const batch = this.queue.splice(0, WORKERS);
8543
- const passed = await Promise.all(batch.map((post2) => this.passes(post2)));
9554
+ const batch = board.queue.splice(0, WORKERS);
9555
+ const passed = await Promise.all(batch.map((post2) => this.passes(site, post2)));
9556
+ this.saveProbes(site);
8544
9557
  if (gen !== this.gen) {
8545
9558
  return;
8546
9559
  }
8547
9560
  batch.forEach((post2, i) => {
8548
- view.checked++;
9561
+ board.checked++;
8549
9562
  if (passed[i]) {
8550
- this.admit(post2);
9563
+ this.admit(site, post2);
8551
9564
  }
8552
9565
  });
9566
+ this.show();
8553
9567
  this.draw();
8554
9568
  }
8555
9569
  if (gen === this.gen) {
8556
- view.searching = false;
9570
+ board.searching = false;
9571
+ this.show();
8557
9572
  this.draw();
8558
9573
  }
8559
9574
  } catch (error) {
@@ -8564,37 +9579,62 @@ class Finder {
8564
9579
  }
8565
9580
  }
8566
9581
  }
8567
- async passes(post2) {
8568
- if (!safe(post2)) {
9582
+ async passes(site, post2) {
9583
+ const version2 = rendition(post2);
9584
+ if (!rated(site, post2, this.view.rating) || exposed(post2, this.view.block).length > 0 || !version2) {
8569
9585
  return false;
8570
9586
  }
8571
9587
  if (this.view.preset === "all") {
8572
- return ["png", "jpg", "jpeg"].includes(post2.ext);
9588
+ return ["png", "jpg", "jpeg"].includes(version2.ext);
8573
9589
  }
8574
9590
  if (post2.ext !== "png") {
8575
9591
  return false;
8576
9592
  }
8577
- if (this.site.vouched) {
8578
- return true;
9593
+ return site.vouched || this.transparent(site, post2);
9594
+ }
9595
+ probes(site) {
9596
+ let known = this.probed.get(site.key);
9597
+ if (!known) {
9598
+ known = readCache(site, "probes.json");
9599
+ this.probed.set(site.key, known);
9600
+ }
9601
+ return known;
9602
+ }
9603
+ async transparent(site, post2) {
9604
+ const known = this.probes(site);
9605
+ const cached = known[post2.id];
9606
+ if (cached !== undefined) {
9607
+ return cached;
8579
9608
  }
8580
9609
  try {
8581
- return (await headOf(this.site, post2.file, this.signal))?.alpha === true;
9610
+ const alpha = (await headOf(site, post2.file, this.signal))?.alpha === true;
9611
+ known[post2.id] = alpha;
9612
+ this.unsaved.add(site.key);
9613
+ return alpha;
8582
9614
  } catch {
8583
9615
  return false;
8584
9616
  }
8585
9617
  }
8586
- admit(post2) {
8587
- const tile = {
8588
- id: post2.id,
8589
- width: post2.width,
8590
- height: post2.height,
8591
- owner: post2.owner,
8592
- origin: originHost(post2.source),
8593
- mates: this.owners.get(this.site.key)?.get(post2.owner) ?? []
8594
- };
8595
- this.posts.set(post2.id, post2);
8596
- this.view.tiles.push(tile);
8597
- this.thumbQueue.push(tile);
9618
+ saveProbes(site) {
9619
+ if (this.unsaved.delete(site.key)) {
9620
+ writeCache(site, "probes.json", this.probes(site));
9621
+ }
9622
+ }
9623
+ admit(site, post2) {
9624
+ this.posts.set(this.mark(site, post2.id), post2);
9625
+ const last = this.view.sets === "fold" ? this.board.groups.at(-1) : undefined;
9626
+ const head = last?.posts[0];
9627
+ const credit = post2.owner || post2.artist;
9628
+ if (last && head && credit && (head.owner || head.artist) === credit && head.width === post2.width && head.height === post2.height) {
9629
+ last.posts.push(post2);
9630
+ if (last.open) {
9631
+ this.thumbQueue.push({ site, post: post2 });
9632
+ this.thumbs();
9633
+ }
9634
+ return;
9635
+ }
9636
+ this.board.groups.push({ posts: [post2], open: false });
9637
+ this.thumbQueue.push({ site, post: post2 });
8598
9638
  this.thumbs();
8599
9639
  }
8600
9640
  async mateOwners(site) {
@@ -8603,11 +9643,7 @@ class Finder {
8603
9643
  return cached;
8604
9644
  }
8605
9645
  const found = origins(this.home);
8606
- const cachePath = join7(cacheDir(site), "owners.json");
8607
- let known = {};
8608
- try {
8609
- known = JSON.parse(readFileSync4(cachePath, "utf8"));
8610
- } catch {}
9646
+ const known = readCache(site, "owners.json");
8611
9647
  const byPalette = new Map;
8612
9648
  for (const sibling of this.catalog.palettes) {
8613
9649
  const origin = found.get(sibling.name);
@@ -8625,48 +9661,54 @@ class Finder {
8625
9661
  }
8626
9662
  byPalette.set(sibling.name, owner);
8627
9663
  }
8628
- mkdirSync4(dirname3(cachePath), { recursive: true });
8629
- writeFileSync4(cachePath, `${JSON.stringify(known)}
8630
- `);
9664
+ writeCache(site, "owners.json", known);
8631
9665
  const owners = mates(byPalette);
8632
9666
  this.owners.set(site.key, owners);
8633
9667
  return owners;
8634
9668
  }
8635
- async cached(site, path2, url) {
9669
+ async cached(site, path2, url, progress2) {
8636
9670
  if (existsSync3(path2)) {
8637
- return new Uint8Array(readFileSync4(path2));
9671
+ return new Uint8Array(readFileSync5(path2));
9672
+ }
9673
+ const running = this.inflight.get(path2);
9674
+ if (running) {
9675
+ return running;
9676
+ }
9677
+ const job = (async () => {
9678
+ const bytes = await fetchBytes(site, url, this.signal, progress2);
9679
+ mkdirSync4(dirname3(path2), { recursive: true });
9680
+ writeFileSync4(path2, bytes);
9681
+ return bytes;
9682
+ })();
9683
+ this.inflight.set(path2, job);
9684
+ try {
9685
+ return await job;
9686
+ } finally {
9687
+ this.inflight.delete(path2);
8638
9688
  }
8639
- const bytes = await fetchBytes(site, url, this.signal);
8640
- mkdirSync4(dirname3(path2), { recursive: true });
8641
- writeFileSync4(path2, bytes);
8642
- return bytes;
8643
9689
  }
8644
9690
  thumbs() {
8645
9691
  while (this.thumbing < WORKERS && this.thumbQueue.length > 0) {
8646
- const tile = this.thumbQueue.shift();
9692
+ const { site, post: post2 } = this.thumbQueue.shift();
8647
9693
  this.thumbing++;
8648
- this.thumb(tile).catch(() => {}).finally(() => {
9694
+ this.thumb(site, post2).catch(() => {}).finally(() => {
8649
9695
  this.thumbing--;
8650
9696
  this.thumbs();
8651
9697
  });
8652
9698
  }
8653
9699
  }
8654
- async thumb(tile) {
8655
- const post2 = this.posts.get(tile.id);
8656
- const site = this.site;
8657
- if (!post2) {
8658
- return;
8659
- }
9700
+ async thumb(site, post2) {
8660
9701
  const w = TILE.cols * this.cell.w;
8661
9702
  const h = TILE.rows * this.cell.h;
8662
- const path2 = join7(cacheDir(site), "tile", `${tile.id}-${w}x${h}.png`);
9703
+ const path2 = join7(this.scratch, "tile", `${site.key}-${post2.id}-${w}x${h}.png`);
8663
9704
  if (!existsSync3(path2)) {
8664
- const thumb = join7(cacheDir(site), "thumb", `${tile.id}.${extension(post2.preview)}`);
9705
+ const thumb = join7(cacheDir(site), "thumb", `${post2.id}.${extension(post2.preview)}`);
8665
9706
  const bytes = await this.cached(site, thumb, post2.preview);
8666
9707
  mkdirSync4(dirname3(path2), { recursive: true });
8667
- writeFileSync4(path2, encodePng(contain(decodeImage(bytes), w, h)));
9708
+ writeFileSync4(path2, encodePng(contain(decodeImage(bytes, MAX_PIXELS), w, h)));
8668
9709
  }
8669
- tile.thumb = path2;
9710
+ this.thumbPath.set(this.mark(site, post2.id), path2);
9711
+ this.show();
8670
9712
  this.draw();
8671
9713
  }
8672
9714
  select() {
@@ -8688,31 +9730,26 @@ class Finder {
8688
9730
  }
8689
9731
  async load(tile) {
8690
9732
  const view = this.view;
8691
- const post2 = this.posts.get(tile.id);
8692
- if (!post2) {
9733
+ const post2 = this.posts.get(this.mark(this.site, tile.id));
9734
+ const version2 = post2 && rendition(post2);
9735
+ if (!version2) {
8693
9736
  return;
8694
9737
  }
8695
9738
  const site = this.site;
8696
9739
  const control = new AbortController;
8697
9740
  this.fetch = control;
8698
- const signal = AbortSignal.any([this.signal, control.signal]);
8699
9741
  try {
8700
- const path2 = join7(cacheDir(site), "orig", `${tile.id}.${post2.ext}`);
8701
- let bytes;
8702
- if (existsSync3(path2)) {
8703
- bytes = new Uint8Array(readFileSync4(path2));
8704
- } else {
9742
+ const path2 = this.origPath(site, tile.id, version2.ext);
9743
+ if (!existsSync3(path2)) {
8705
9744
  view.fetching = { id: tile.id, got: 0, size: 0 };
8706
9745
  this.draw();
8707
- bytes = await fetchBytes(site, post2.file, signal, (got, size) => {
8708
- if (view.fetching?.id === tile.id) {
8709
- view.fetching = { id: tile.id, got, size };
8710
- this.draw();
8711
- }
8712
- });
8713
- mkdirSync4(dirname3(path2), { recursive: true });
8714
- writeFileSync4(path2, bytes);
8715
9746
  }
9747
+ const bytes = await this.cached(site, path2, version2.file, (got, size) => {
9748
+ if (view.fetching?.id === tile.id) {
9749
+ view.fetching = { id: tile.id, got, size };
9750
+ this.draw();
9751
+ }
9752
+ });
8716
9753
  if (control.signal.aborted) {
8717
9754
  return;
8718
9755
  }
@@ -8720,14 +9757,41 @@ class Finder {
8720
9757
  view.preparing = tile.id;
8721
9758
  this.flush();
8722
9759
  await tick();
8723
- const image = decodeImage(bytes);
9760
+ const image = decodeImage(bytes, MAX_PIXELS);
8724
9761
  if (site !== this.site) {
8725
9762
  return;
8726
9763
  }
8727
- this.current = { site, id: tile.id, image, bytes, ext: post2.ext, clear: transparency(image) };
9764
+ const current = {
9765
+ site,
9766
+ id: tile.id,
9767
+ image,
9768
+ plain: image,
9769
+ failed: false,
9770
+ bytes,
9771
+ ext: version2.ext,
9772
+ clear: transparency(image)
9773
+ };
9774
+ if (current.clear === 0 && canRemoveBackground() && this.setting("TTHEME_FIND_REMOVE_BG") !== "off") {
9775
+ view.preparing = undefined;
9776
+ view.cutting = tile.id;
9777
+ this.flush();
9778
+ const cut = await this.cutOut(site, tile.id, path2, control.signal);
9779
+ if (control.signal.aborted || site !== this.site) {
9780
+ return;
9781
+ }
9782
+ if (cut) {
9783
+ current.cut = cut;
9784
+ current.image = cut;
9785
+ current.clear = transparency(cut);
9786
+ } else {
9787
+ current.failed = true;
9788
+ }
9789
+ }
9790
+ this.current = current;
8728
9791
  if (view.tiles[view.focus]?.id === tile.id) {
8729
9792
  this.present();
8730
9793
  }
9794
+ this.preload();
8731
9795
  } catch (error) {
8732
9796
  if (!control.signal.aborted && !this.signal.aborted) {
8733
9797
  view.error = reason(site, error);
@@ -8739,9 +9803,59 @@ class Finder {
8739
9803
  if (view.preparing === tile.id) {
8740
9804
  view.preparing = undefined;
8741
9805
  }
9806
+ if (view.cutting === tile.id) {
9807
+ view.cutting = undefined;
9808
+ }
8742
9809
  this.draw();
8743
9810
  }
8744
9811
  }
9812
+ async cutOut(site, id, path2, signal) {
9813
+ const out = join7(this.scratch, "cut", `${site.key}-${id}.png`);
9814
+ try {
9815
+ mkdirSync4(dirname3(out), { recursive: true });
9816
+ const cut = existsSync3(out) ? decodeImage(new Uint8Array(readFileSync5(out)), MAX_PIXELS) : await removeBackground(path2, out, signal);
9817
+ return keepable(transparency(cut)) ? cut : undefined;
9818
+ } catch {
9819
+ rmSync4(out, { force: true });
9820
+ return;
9821
+ }
9822
+ }
9823
+ swap() {
9824
+ const current = this.current;
9825
+ if (!current?.cut || this.view.shown?.id !== current.id) {
9826
+ return;
9827
+ }
9828
+ current.image = current.image === current.cut ? current.plain : current.cut;
9829
+ current.clear = transparency(current.image);
9830
+ this.present();
9831
+ this.draw();
9832
+ }
9833
+ preload() {
9834
+ const view = this.view;
9835
+ if (view.mode !== "try") {
9836
+ return;
9837
+ }
9838
+ const site = this.site;
9839
+ for (const index of [view.focus + this.direction, view.focus - this.direction]) {
9840
+ if (this.prefetching >= PRELOAD) {
9841
+ return;
9842
+ }
9843
+ const tile = view.tiles[index];
9844
+ const post2 = tile && this.posts.get(this.mark(site, tile.id));
9845
+ const version2 = post2 && rendition(post2);
9846
+ if (!version2) {
9847
+ continue;
9848
+ }
9849
+ const path2 = this.origPath(site, post2.id, version2.ext);
9850
+ if (existsSync3(path2)) {
9851
+ continue;
9852
+ }
9853
+ this.prefetching++;
9854
+ this.cached(site, path2, version2.file).catch(() => {}).finally(() => {
9855
+ this.prefetching--;
9856
+ });
9857
+ }
9858
+ }
8745
9859
  present() {
8746
9860
  const current = this.current;
8747
9861
  if (!current) {
@@ -8751,11 +9865,12 @@ class Finder {
8751
9865
  const H = this.rows * this.cell.h;
8752
9866
  const width2 = Math.min(W, TRY_WIDTH);
8753
9867
  const height = Math.max(1, Math.round(H * width2 / W));
8754
- const path2 = join7(this.scratch, `${current.id}-${width2}x${height}.png`);
9868
+ const cut = current.cut ? current.image === current.cut ? "on" : "off" : current.failed ? "failed" : "none";
9869
+ const path2 = join7(this.scratch, `${current.id}${cut === "on" ? "c" : ""}-${width2}x${height}.png`);
8755
9870
  if (!existsSync3(path2)) {
8756
9871
  writeFileSync4(path2, encodePng(tryOn(current.image, this.entry, this.tone, width2, height)));
8757
9872
  }
8758
- this.view.shown = { id: current.id, clear: current.clear, bytes: current.bytes.length, path: path2 };
9873
+ this.view.shown = { id: current.id, clear: current.clear, bytes: current.bytes.length, path: path2, cut };
8759
9874
  }
8760
9875
  async install() {
8761
9876
  const view = this.view;
@@ -8825,17 +9940,45 @@ class Finder {
8825
9940
  this.write(`${out}\x1B[?2026l`);
8826
9941
  }
8827
9942
  }
9943
+ function routable() {
9944
+ if (process.versions.bun) {
9945
+ return true;
9946
+ }
9947
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
9948
+ return major > 22 || major === 22 && minor >= 21;
9949
+ }
9950
+ async function relaunch() {
9951
+ if (!routable()) {
9952
+ process.stderr.write(`unblock needs node 22.21 or newer — this is ${process.versions.node}
9953
+ `);
9954
+ return 1;
9955
+ }
9956
+ const proxy = await tunnel();
9957
+ const child = spawn2(process.execPath, process.argv.slice(1), {
9958
+ stdio: "inherit",
9959
+ env: {
9960
+ ...process.env,
9961
+ NODE_USE_ENV_PROXY: "1",
9962
+ NODE_NO_WARNINGS: "1",
9963
+ HTTPS_PROXY: `http://127.0.0.1:${proxy.port}`,
9964
+ TTHEME_FIND_PROXY: String(proxy.port)
9965
+ }
9966
+ });
9967
+ const code = await new Promise((resolve2) => child.on("exit", (status2) => resolve2(status2 ?? 1)));
9968
+ proxy.close();
9969
+ return code;
9970
+ }
8828
9971
  async function runFind(name) {
9972
+ if (process.env.TTHEME_FIND_UNBLOCK === "1" && !process.env.TTHEME_FIND_PROXY) {
9973
+ return relaunch();
9974
+ }
8829
9975
  const home = configHome();
8830
9976
  const catalog = readCatalog(home);
8831
9977
  const entry = find2(catalog.palettes, name);
8832
- if (!entry.booru) {
8833
- throw new Error(`${name} has no booru tag to search for`);
8834
- }
8835
9978
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
8836
9979
  throw new Error("ttheme find needs a terminal");
8837
9980
  }
8838
- return new Finder(home, catalog, entry, entry.booru).run();
9981
+ return new Finder(home, catalog, entry, entry.booru ?? "").run();
8839
9982
  }
8840
9983
 
8841
9984
  // src/init.ts
@@ -8845,7 +9988,7 @@ import {
8845
9988
  existsSync as existsSync5,
8846
9989
  mkdirSync as mkdirSync5,
8847
9990
  readdirSync as readdirSync3,
8848
- readFileSync as readFileSync5,
9991
+ readFileSync as readFileSync6,
8849
9992
  rmSync as rmSync5,
8850
9993
  writeFileSync as writeFileSync5
8851
9994
  } from "node:fs";
@@ -10794,7 +11937,7 @@ function copyDir(copies, from, to) {
10794
11937
  }
10795
11938
  }
10796
11939
  function loadManifest(root2) {
10797
- return JSON.parse(readFileSync5(join8(root2, "dist", "manifest.json"), "utf8"));
11940
+ return JSON.parse(readFileSync6(join8(root2, "dist", "manifest.json"), "utf8"));
10798
11941
  }
10799
11942
  function planInit(opts, paths) {
10800
11943
  const dist = join8(paths.root, "dist");
@@ -10810,7 +11953,7 @@ function planInit(opts, paths) {
10810
11953
  const configPath = join8(home, "config.zsh");
10811
11954
  const settings2 = {
10812
11955
  file: configPath,
10813
- content: configFile(existsSync5(configPath) ? readFileSync5(configPath, "utf8") : "")
11956
+ content: configFile(existsSync5(configPath) ? readFileSync6(configPath, "utf8") : "")
10814
11957
  };
10815
11958
  const notes = [];
10816
11959
  if (opts.terminals.includes("ghostty")) {
@@ -10818,7 +11961,7 @@ function planInit(opts, paths) {
10818
11961
  }
10819
11962
  if (opts.terminals.includes("alacritty")) {
10820
11963
  const config = join8(paths.configHome, "alacritty", "alacritty.toml");
10821
- if (existsSync5(config) && !readFileSync5(config, "utf8").includes("# ttheme begin")) {
11964
+ if (existsSync5(config) && !readFileSync6(config, "utf8").includes("# ttheme begin")) {
10822
11965
  notes.push("alacritty.toml already exists — ttheme left it alone; add its themes/ import yourself");
10823
11966
  }
10824
11967
  }
@@ -10843,7 +11986,7 @@ function applyInit(plan) {
10843
11986
  sync(configHome2, plan.catalog, plan.installed);
10844
11987
  for (const e of plan.edits) {
10845
11988
  mkdirSync5(dirname4(e.file), { recursive: true });
10846
- const current = existsSync5(e.file) ? readFileSync5(e.file, "utf8") : "";
11989
+ const current = existsSync5(e.file) ? readFileSync6(e.file, "utf8") : "";
10847
11990
  writeFileSync5(e.file, upsertBlock(current, e.block));
10848
11991
  }
10849
11992
  }
@@ -10866,7 +12009,7 @@ function verify(plan) {
10866
12009
  const missing = [
10867
12010
  ...plan.copies.filter((c2) => !existsSync5(c2.to)).map((c2) => c2.to),
10868
12011
  ...existsSync5(plan.settings.file) ? [] : [plan.settings.file],
10869
- ...plan.edits.filter((e) => !readFileSync5(e.file, "utf8").includes("# ttheme begin")).map((e) => e.file)
12012
+ ...plan.edits.filter((e) => !readFileSync6(e.file, "utf8").includes("# ttheme begin")).map((e) => e.file)
10870
12013
  ];
10871
12014
  if (missing.length > 0) {
10872
12015
  throw new Error(`init left gaps: