A terminal-based sorter for Nijisanji livers
0

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit

Ufal Salman (Jul 14, 2026, 7:53 PM +0700) a46d3041

+3436
+10
.gitignore
··· 1 + node_modules/ 2 + 3 + dist/ 4 + 5 + .DS_Store 6 + 7 + .vscode/ 8 + .idea/ 9 + *.swp 10 + *.swo
+95
README.md
··· 1 + # better-nijisort-cli 2 + 3 + A terminal-based sorter for Nijisanji livers. Pick between two livers one at a time — the app uses a merge sort algorithm to produce a ranked list from your choices. 4 + 5 + ## Usage 6 + 7 + ### Run from source 8 + 9 + ```bash 10 + npm install 11 + node src/index.js 12 + # or 13 + npm start 14 + ``` 15 + 16 + ### Prebuilt binaries 17 + 18 + Download the binary for your platform from the [Releases](../../releases) page and run it directly — no Node.js required. 19 + 20 + | Platform | File | 21 + |---|---| 22 + | Linux | `better-nijisort-cli-linux` | 23 + | macOS | `better-nijisort-cli-macos` | 24 + | Windows | `better-nijisort-cli-win.exe` | 25 + 26 + On Linux/macOS, make it executable first: 27 + 28 + ```bash 29 + chmod +x better-nijisort-cli-linux 30 + ./better-nijisort-cli-linux 31 + ``` 32 + 33 + On macOS you may need to clear the quarantine flag: 34 + 35 + ```bash 36 + xattr -d com.apple.quarantine better-nijisort-cli-macos 37 + ``` 38 + 39 + ## How it works 40 + 41 + 1. **Filter** — choose which branches and genders to include 42 + 2. **Sort** — pick your preferred liver in each head-to-head matchup 43 + 3. **Results** — view the final ranking in a table 44 + 45 + ## Controls 46 + 47 + ### Filter screen 48 + 49 + | Key | Action | 50 + |---|---| 51 + | `↑` / `k` | Move up | 52 + | `↓` / `j` | Move down | 53 + | `Space` | Toggle option | 54 + | `Enter` | Start sorting | 55 + 56 + ### Sorting screen 57 + 58 + | Key | Action | 59 + |---|---| 60 + | `←` / `h` | Pick left | 61 + | `→` / `l` | Pick right | 62 + | `↑` / `k` | Tie | 63 + | `↓` / `j` | Undo last pick | 64 + | `Ctrl-C` | Quit | 65 + 66 + ### Results screen 67 + 68 + | Key | Action | 69 + |---|---| 70 + | `1` | Show top 5 | 71 + | `2` | Show top 10 | 72 + | `3` | Show top 20 | 73 + | `q` / `Ctrl-C` | Quit | 74 + 75 + ## Dataset 76 + 77 + Dataset version: **2026-05-19** · 274 livers across にじさんじ, NIJISANJI EN, ex-ID, ex-KR, and NIJISANJI IN. 78 + 79 + ## Build from source 80 + 81 + Requires Node.js 22+. 82 + 83 + ```bash 84 + npm install 85 + npm run build 86 + # outputs to dist/ 87 + ``` 88 + 89 + Binaries are built with [@yao-pkg/pkg](https://github.com/yao-pkg/pkg). 90 + 91 + ## Credits 92 + 93 + - [execfera's Touhou Project character sorter (tohosort)](https://tohosort.frelia.my/) — original sorter concept and source code 94 + - [rennelu's Nijisanji liver sorter](https://rennelu.github.io/nijisort) — the Nijisanji adaptation this is based on 95 + - [better-nijisort (web)](https://nijisanji.sorter.ufal.my.id/) — the web version this CLI was built from
+61
package-lock.json
··· 1 + { 2 + "name": "better-nijisort-cli", 3 + "version": "1.0.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "better-nijisort-cli", 9 + "version": "1.0.0", 10 + "dependencies": { 11 + "enquirer": "^2.4.1" 12 + }, 13 + "bin": { 14 + "nijisort": "src/index.js" 15 + } 16 + }, 17 + "node_modules/ansi-colors": { 18 + "version": "4.1.3", 19 + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", 20 + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", 21 + "license": "MIT", 22 + "engines": { 23 + "node": ">=6" 24 + } 25 + }, 26 + "node_modules/ansi-regex": { 27 + "version": "5.0.1", 28 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 29 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 30 + "license": "MIT", 31 + "engines": { 32 + "node": ">=8" 33 + } 34 + }, 35 + "node_modules/enquirer": { 36 + "version": "2.4.1", 37 + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", 38 + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", 39 + "license": "MIT", 40 + "dependencies": { 41 + "ansi-colors": "^4.1.1", 42 + "strip-ansi": "^6.0.1" 43 + }, 44 + "engines": { 45 + "node": ">=8.6" 46 + } 47 + }, 48 + "node_modules/strip-ansi": { 49 + "version": "6.0.1", 50 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 51 + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 52 + "license": "MIT", 53 + "dependencies": { 54 + "ansi-regex": "^5.0.1" 55 + }, 56 + "engines": { 57 + "node": ">=8" 58 + } 59 + } 60 + } 61 + }
+25
package.json
··· 1 + { 2 + "name": "better-nijisort-cli", 3 + "version": "1.0.0", 4 + "description": "A CLI sorter for Nijisanji livers", 5 + "main": "src/index.js", 6 + "bin": { 7 + "nijisort": "./src/index.js" 8 + }, 9 + "scripts": { 10 + "start": "node src/index.js", 11 + "build": "npx @yao-pkg/pkg . --targets node22-linux-x64,node22-win-x64,node22-macos-x64 --out-path dist" 12 + }, 13 + "pkg": { 14 + "assets": [ 15 + "src/data/*.js", 16 + "html/src/assets/liver/**" 17 + ], 18 + "scripts": [ 19 + "src/**/*.js" 20 + ] 21 + }, 22 + "dependencies": { 23 + "enquirer": "^2.4.1" 24 + } 25 + }
+48
src/data.js
··· 1 + /** 2 + * Data loader — reads the latest dataset from html/src/js/data/ 3 + * Uses Node's vm module to safely evaluate the JS data files. 4 + */ 5 + 6 + const fs = require('fs'); 7 + const path = require('path'); 8 + const vm = require('vm'); 9 + 10 + const DATA_DIR = path.join(__dirname, 'data'); 11 + 12 + function loadLatestDataset() { 13 + const files = fs.readdirSync(DATA_DIR) 14 + .filter(f => /^\d{4}-\d{2}-\d{2}\.js$/.test(f)) 15 + .sort(); // lexicographic sort works for YYYY-MM-DD 16 + 17 + if (files.length === 0) { 18 + throw new Error('No dataset files found in ' + DATA_DIR); 19 + } 20 + 21 + const latestFile = files[files.length - 1]; 22 + const filePath = path.join(DATA_DIR, latestFile); 23 + const code = fs.readFileSync(filePath, 'utf8'); 24 + 25 + // Set up the sandbox that the data file expects 26 + const sandbox = { 27 + dataSet: {}, 28 + dataSetVersion: '', 29 + }; 30 + 31 + vm.createContext(sandbox); 32 + vm.runInContext(code, sandbox); 33 + 34 + const version = sandbox.dataSetVersion; 35 + const data = sandbox.dataSet[version]; 36 + 37 + if (!data) { 38 + throw new Error(`Dataset '${version}' not found in file ${latestFile}`); 39 + } 40 + 41 + return { 42 + version, 43 + options: data.options, 44 + characterData: data.characterData, 45 + }; 46 + } 47 + 48 + module.exports = { loadLatestDataset };
+2302
src/data/2026-05-19.js
··· 1 + dataSetVersion = "2026-05-19"; // Change this when creating a new data set version. YYYY-MM-DD format. 2 + dataSet[dataSetVersion] = {}; 3 + 4 + dataSet[dataSetVersion].options = [ 5 + { 6 + name: "Filter by branch", 7 + key: "branch", 8 + tooltip: "Check this to restrict to livers from certain branches.", 9 + checked: false, 10 + sub: [ 11 + { name: "にじさんじ", key: "jp" }, 12 + { name: "NIJISANJI ex-ID", key: "id" }, 13 + { name: "NIJISANJI ex-KR", key: "kr" }, 14 + { name: "NIJISANJI EN", key: "en" }, 15 + { name: "NIJISANJI IN", key: "in" } 16 + ] 17 + }, 18 + { 19 + name: "Exclude boys", 20 + key: "boy", 21 + tooltip: "Check this to exclude boys.", 22 + checked: false 23 + }, 24 + { 25 + name: "Exclude girls", 26 + key: "girl", 27 + tooltip: "Check this to exclude girls.", 28 + checked: false 29 + }, 30 + { 31 + name: "Exclude retired members", 32 + key: "retired", 33 + tooltip: "Check this to excluded graduated/retired/terminated members.", 34 + checked: true 35 + } 36 + ]; 37 + 38 + dataSet[dataSetVersion].characterData = [ 39 + { 40 + name: "Tsukino Mito", 41 + img: "mito.webp", 42 + opts: { 43 + branch: ['jp'], 44 + girl: true 45 + } 46 + }, 47 + { 48 + name: "Yuki Chihiro", 49 + img: "chihiro.webp", 50 + opts: { 51 + branch: ['jp'], 52 + girl: true, 53 + retired: true 54 + } 55 + }, 56 + { 57 + name: "Elu", 58 + img: "elu.webp", 59 + opts: { 60 + branch: ['jp'], 61 + girl: true 62 + } 63 + }, 64 + { 65 + name: "Higuchi Kaede", 66 + img: "kaede.webp", 67 + opts: { 68 + branch: ['jp'], 69 + girl: true 70 + } 71 + }, 72 + { 73 + name: "Shizuka Rin", 74 + img: "rin.webp", 75 + opts: { 76 + branch: ['jp'], 77 + girl: true 78 + } 79 + }, 80 + { 81 + name: "Shibuya Hajime", 82 + img: "hajime.webp", 83 + opts: { 84 + branch: ['jp'], 85 + boy: true 86 + } 87 + }, 88 + { 89 + name: "Suzuya Aki", 90 + img: "aki.webp", 91 + opts: { 92 + branch: ['jp'], 93 + boy: true, 94 + retired: true 95 + } 96 + }, 97 + { 98 + name: "Moira", 99 + img: "moira.webp", 100 + opts: { 101 + branch: ['jp'], 102 + girl: true 103 + } 104 + }, 105 + { 106 + name: "Mononobe Alice", 107 + img: "alice.webp", 108 + opts: { 109 + branch: ['jp'], 110 + girl: true 111 + } 112 + }, 113 + { 114 + name: "Fushimi Gaku", 115 + img: "gaku.webp", 116 + opts: { 117 + branch: ['jp'], 118 + boy: true 119 + } 120 + }, 121 + { 122 + name: "Ienaga Mugi", 123 + img: "mugi.webp", 124 + opts: { 125 + branch: ['jp'], 126 + girl: true 127 + } 128 + }, 129 + { 130 + name: "Morinaka Kazaki", 131 + img: "kazaki.webp", 132 + opts: { 133 + branch: ['jp'], 134 + girl: true 135 + } 136 + }, 137 + { 138 + name: "Suzuka Utako", 139 + img: "utako.png", 140 + opts: { 141 + branch: ['jp'], 142 + girl: true, 143 + retired: true 144 + } 145 + }, 146 + { 147 + name: "Ushimi Ichigo", 148 + img: "ichigo.webp", 149 + opts: { 150 + branch: ['jp'], 151 + girl: true 152 + } 153 + }, 154 + { 155 + name: "Yuuhi Riri", 156 + img: "riri.webp", 157 + opts: { 158 + branch: ['jp'], 159 + girl: true 160 + } 161 + }, 162 + { 163 + name: "Kenmochi Toya", 164 + img: "toya.webp", 165 + opts: { 166 + branch: ['jp'], 167 + boy: true 168 + } 169 + }, 170 + { 171 + name: "Gilzaren III", 172 + img: "gilzaren.png", 173 + opts: { 174 + branch: ['jp'], 175 + boy: true 176 + } 177 + }, 178 + { 179 + name: "Fumino Tamaki", 180 + img: "tamaki.webp", 181 + opts: { 182 + branch: ['jp'], 183 + girl: true 184 + } 185 + }, 186 + { 187 + name: "Kanae", 188 + img: "kanae.png", 189 + opts: { 190 + branch: ['jp'], 191 + boy: true 192 + } 193 + }, 194 + { 195 + name: "Akabane Youko", 196 + img: "yoko.png", 197 + opts: { 198 + branch: ['jp'], 199 + girl: true 200 + } 201 + }, 202 + { 203 + name: "Sasaki Saku", 204 + img: "saku.webp", 205 + opts: { 206 + branch: ['jp'], 207 + girl: true 208 + } 209 + }, 210 + { 211 + name: "Yamiyono Moruru", 212 + img: "moruru.png", 213 + opts: { 214 + branch: ['jp'], 215 + girl: true, 216 + retired: true 217 + } 218 + }, 219 + { 220 + name: "Honma Himawari", 221 + img: "himawari.png", 222 + opts: { 223 + branch: ['jp'], 224 + girl: true 225 + } 226 + }, 227 + { 228 + name: "Makaino Ririmu", 229 + img: "ririmu.webp", 230 + opts: { 231 + branch: ['jp'], 232 + girl: true 233 + } 234 + }, 235 + { 236 + name: "Kuzuha", 237 + img: "kuzuha.webp", 238 + opts: { 239 + branch: ['jp'], 240 + boy: true 241 + } 242 + }, 243 + { 244 + name: "Setsuna", 245 + img: "setsuna.png", 246 + opts: { 247 + branch: ['jp'], 248 + girl: true, 249 + retired: true 250 + } 251 + }, 252 + { 253 + name: "Shiina Yuika", 254 + img: "yuika.webp", 255 + opts: { 256 + branch: ['jp'], 257 + girl: true 258 + } 259 + }, 260 + { 261 + name: "Dola", 262 + img: "dola.png", 263 + opts: { 264 + branch: ['jp'], 265 + girl: true 266 + } 267 + }, 268 + { 269 + name: "Hanabatake Chaika", 270 + img: "chaika.webp", 271 + opts: { 272 + branch: ['jp'], 273 + boy: true 274 + } 275 + }, 276 + { 277 + name: "Hassaku Yuzu", 278 + img: "yuzu.png", 279 + opts: { 280 + branch: ['jp'], 281 + girl: true, 282 + retired: true 283 + } 284 + }, 285 + { 286 + name: "Izumo Kasumi", 287 + img: "kasumi.png", 288 + opts: { 289 + branch: ['jp'], 290 + girl: true, 291 + retired: true 292 + } 293 + }, 294 + { 295 + name: "Azuchi Momo", 296 + img: "momo.webp", 297 + opts: { 298 + branch: ['jp'], 299 + girl: true, 300 + retired: true 301 + } 302 + }, 303 + { 304 + name: "Nakao Azuma", 305 + img: "azuma.png", 306 + opts: { 307 + branch: ['jp'], 308 + boy: true, 309 + retired: true 310 + } 311 + }, 312 + { 313 + name: "Naruto Kogane", 314 + img: "kogane.png", 315 + opts: { 316 + branch: ['jp'], 317 + girl: true, 318 + retired: true 319 + } 320 + }, 321 + { 322 + name: "Ryushen", 323 + img: "ryushen.png", 324 + opts: { 325 + branch: ['jp'], 326 + } 327 + }, 328 + { 329 + name: "Sister Claire", 330 + img: "claire.webp", 331 + opts: { 332 + branch: ['jp'], 333 + girl: true 334 + } 335 + }, 336 + { 337 + name: "Suzuki Masaru", 338 + img: "masaru.webp", 339 + opts: { 340 + branch: ['jp'], 341 + boy: true 342 + } 343 + }, 344 + { 345 + name: "Todoroki Kyoko", 346 + img: "kyoko.png", 347 + opts: { 348 + branch: ['jp'], 349 + girl: true 350 + } 351 + }, 352 + { 353 + name: "Umiyashano Kami", 354 + img: "kami.png", 355 + opts: { 356 + branch: ['jp'], 357 + boy: true, 358 + retired: true 359 + } 360 + }, 361 + { 362 + name: "Uzuki Kou", 363 + img: "kou.webp", 364 + opts: { 365 + branch: ['jp'], 366 + boy: true 367 + } 368 + }, 369 + { 370 + name: "Yashiro Kizuku", 371 + img: "kizuku.webp", 372 + opts: { 373 + branch: ['jp'], 374 + boy: true 375 + } 376 + }, 377 + { 378 + name: "Kanda Shoichi", 379 + img: "shoichi.webp", 380 + opts: { 381 + branch: ['jp'], 382 + boy: true 383 + } 384 + }, 385 + { 386 + name: "Amemori Sayo", 387 + img: "sayo.webp", 388 + opts: { 389 + branch: ['jp'], 390 + girl: true 391 + } 392 + }, 393 + { 394 + name: "Takamiya Rion", 395 + img: "rion.webp", 396 + opts: { 397 + branch: ['jp'], 398 + girl: true 399 + } 400 + }, 401 + { 402 + name: "Asuka Hina", 403 + img: "hina.webp", 404 + opts: { 405 + branch: ['jp'], 406 + girl: true 407 + } 408 + }, 409 + { 410 + name: "Maimoto Keisuke", 411 + img: "keisuke.webp", 412 + opts: { 413 + branch: ['jp'], 414 + boy: true 415 + } 416 + }, 417 + { 418 + name: "Debidebi Debiru", 419 + img: "debiru.webp", 420 + opts: { 421 + branch: ['jp'], 422 + } 423 + }, 424 + { 425 + name: "Rindou Mikoto", 426 + img: "mikoto.webp", 427 + opts: { 428 + branch: ['jp'], 429 + girl: true 430 + } 431 + }, 432 + { 433 + name: "Joe Rikiichi", 434 + img: "joe.png", 435 + opts: { 436 + branch: ['jp'], 437 + boy: true 438 + } 439 + }, 440 + { 441 + name: "Machita Chima", 442 + img: "chima.png", 443 + opts: { 444 + branch: ['jp'], 445 + girl: true 446 + } 447 + }, 448 + { 449 + name: "Tsukimi Shizuku", 450 + img: "shizuku.png", 451 + opts: { 452 + branch: ['jp'], 453 + girl: true, 454 + retired: true 455 + } 456 + }, 457 + { 458 + name: "Sakura Ritsuki", 459 + img: "ritsuki.webp", 460 + opts: { 461 + branch: ['jp'], 462 + girl: true 463 + } 464 + }, 465 + { 466 + name: "Achikita Chinami", 467 + img: "chinami.png", 468 + opts: { 469 + branch: ['jp'], 470 + girl: true, 471 + retired: true 472 + } 473 + }, 474 + { 475 + name: "Belmond Banderas", 476 + img: "belmond.webp", 477 + opts: { 478 + branch: ['jp'], 479 + boy: true 480 + } 481 + }, 482 + { 483 + name: "Yaguruma Rine", 484 + img: "rine.png", 485 + opts: { 486 + branch: ['jp'], 487 + girl: true 488 + } 489 + }, 490 + { 491 + name: "Yumeoi Kakeru", 492 + img: "kakeru.webp", 493 + opts: { 494 + branch: ['jp'], 495 + boy: true 496 + } 497 + }, 498 + { 499 + name: "Kuroi Shiba", 500 + img: "shiba.webp", 501 + opts: { 502 + branch: ['jp'], 503 + girl: true 504 + } 505 + }, 506 + { 507 + name: "Harusaki Air", 508 + img: "air.webp", 509 + opts: { 510 + branch: ['jp'], 511 + boy: true 512 + } 513 + }, 514 + { 515 + name: "Naruse Naru", 516 + img: "naru.webp", 517 + opts: { 518 + branch: ['jp'], 519 + boy: true, 520 + retired: true 521 + } 522 + }, 523 + { 524 + name: "Warabeda Meiji", 525 + img: "meiji.png", 526 + opts: { 527 + branch: ['jp'], 528 + girl: true, 529 + retired: true 530 + } 531 + }, 532 + { 533 + name: "Kudo Chitose", 534 + img: "chitose.png", 535 + opts: { 536 + branch: ['jp'], 537 + girl: true, 538 + retired: true 539 + } 540 + }, 541 + { 542 + name: "Gundou Mirei", 543 + img: "gundou.png", 544 + opts: { 545 + branch: ['jp'], 546 + girl: true, 547 + retired: true 548 + } 549 + }, 550 + { 551 + name: "Yuzuki Roa", 552 + img: "roa.webp", 553 + opts: { 554 + branch: ['jp'], 555 + girl: true 556 + } 557 + }, 558 + { 559 + name: "Onomachi Haruka", 560 + img: "haruka.webp", 561 + opts: { 562 + branch: ['jp'], 563 + girl: true 564 + } 565 + }, 566 + { 567 + name: "Kataribe Tsumugu", 568 + img: "tsumugu.webp", 569 + opts: { 570 + branch: ['jp'], 571 + girl: true 572 + } 573 + }, 574 + { 575 + name: "Seto Miyako", 576 + img: "miyako.webp", 577 + opts: { 578 + branch: ['jp'], 579 + girl: true, 580 + retired: true 581 + } 582 + }, 583 + { 584 + name: "Shindo Raito", 585 + img: "raito.png", 586 + opts: { 587 + branch: ['jp'], 588 + boy: true, 589 + retired: true 590 + } 591 + }, 592 + { 593 + name: "Otogibara Era", 594 + img: "era.png", 595 + opts: { 596 + branch: ['jp'], 597 + girl: true, 598 + retired: true 599 + } 600 + }, 601 + { 602 + name: "Inui Toko", 603 + img: "mybini5.webp", 604 + opts: { 605 + branch: ['jp'], 606 + girl: true 607 + } 608 + }, 609 + { 610 + name: "Ange Katrina", 611 + img: "ange.png", 612 + opts: { 613 + branch: ['jp'], 614 + girl: true 615 + } 616 + }, 617 + { 618 + name: "Lize Helesta", 619 + img: "lize.webp", 620 + opts: { 621 + branch: ['jp'], 622 + girl: true 623 + } 624 + }, 625 + { 626 + name: "Saegusa Akina", 627 + img: "akina.webp", 628 + opts: { 629 + branch: ['jp'], 630 + boy: true 631 + } 632 + }, 633 + { 634 + name: "Aizono Manami", 635 + img: "manami.png", 636 + opts: { 637 + branch: ['jp'], 638 + girl: true 639 + } 640 + }, 641 + { 642 + name: "Suzuhara Lulu", 643 + img: "lulu.png", 644 + opts: { 645 + branch: ['jp'], 646 + girl: true, 647 + retired: true 648 + } 649 + }, 650 + { 651 + name: "Yukishiro Mahiro", 652 + img: "mahiro.webp", 653 + opts: { 654 + branch: ['jp'], 655 + girl: true 656 + } 657 + }, 658 + { 659 + name: "Ex Albio", 660 + img: "albio.webp", 661 + opts: { 662 + branch: ['jp'], 663 + boy: true 664 + } 665 + }, 666 + { 667 + name: "Levi Elipha", 668 + img: "levi.webp", 669 + opts: { 670 + branch: ['jp'], 671 + girl: true 672 + } 673 + }, 674 + { 675 + name: "Hayama Marin", 676 + img: "marin.png", 677 + opts: { 678 + branch: ['jp'], 679 + girl: true 680 + } 681 + }, 682 + { 683 + name: "Nui Sociere", 684 + img: "nui.png", 685 + opts: { 686 + branch: ['jp'], 687 + girl: true 688 + } 689 + }, 690 + { 691 + name: "Hakase Fuyuki", 692 + img: "hakase.webp", 693 + opts: { 694 + branch: ['jp'], 695 + girl: true 696 + } 697 + }, 698 + { 699 + name: "Kagami Hayato", 700 + img: "hayato.webp", 701 + opts: { 702 + branch: ['jp'], 703 + boy: true 704 + } 705 + }, 706 + { 707 + name: "Yorumi Rena", 708 + img: "rena.webp", 709 + opts: { 710 + branch: ['jp'], 711 + girl: true 712 + } 713 + }, 714 + { 715 + name: "Mayuzumi Kai", 716 + img: "kai.png", 717 + opts: { 718 + branch: ['jp'], 719 + boy: true, 720 + retired: true 721 + } 722 + }, 723 + { 724 + name: "Ars Almal", 725 + img: "ars.webp", 726 + opts: { 727 + branch: ['jp'], 728 + girl: true 729 + } 730 + }, 731 + { 732 + name: "Aiba Uiha", 733 + img: "uiha.webp", 734 + opts: { 735 + branch: ['jp'], 736 + girl: true, 737 + retired: true 738 + } 739 + }, 740 + { 741 + name: "Amamiya Kokoro", 742 + img: "kokoro.webp", 743 + opts: { 744 + branch: ['jp'], 745 + girl: true 746 + } 747 + }, 748 + { 749 + name: "Eli Conifer", 750 + img: "eli.webp", 751 + opts: { 752 + branch: ['jp'], 753 + girl: true 754 + } 755 + }, 756 + { 757 + name: "Ratna Petit", 758 + img: "ratna.webp", 759 + opts: { 760 + branch: ['jp'], 761 + girl: true 762 + } 763 + }, 764 + { 765 + name: "Hayase Sou", 766 + img: "sou.webp", 767 + opts: { 768 + branch: ['jp'], 769 + girl: true 770 + } 771 + }, 772 + { 773 + name: "Sukoya Kana", 774 + img: "kana.webp", 775 + opts: { 776 + branch: ['jp'], 777 + girl: true 778 + } 779 + }, 780 + { 781 + name: "Shellin Burgundy", 782 + img: "shellin.webp", 783 + opts: { 784 + branch: ['jp'], 785 + boy: true 786 + } 787 + }, 788 + { 789 + name: "Fumi", 790 + img: "fumi.webp", 791 + opts: { 792 + branch: ['jp'], 793 + girl: true 794 + } 795 + }, 796 + { 797 + name: "Hoshikawa Sara", 798 + img: "sara.webp", 799 + opts: { 800 + branch: ['jp'], 801 + girl: true 802 + } 803 + }, 804 + { 805 + name: "Yamagami Karuta", 806 + img: "karuta.png", 807 + opts: { 808 + branch: ['jp'], 809 + girl: true 810 + } 811 + }, 812 + { 813 + name: "Matsukai Mao", 814 + img: "mao.webp", 815 + opts: { 816 + branch: ['jp'], 817 + girl: true 818 + } 819 + }, 820 + { 821 + name: "Emma★August", 822 + img: "emma.png", 823 + opts: { 824 + branch: ['jp'], 825 + girl: true 826 + } 827 + }, 828 + { 829 + name: "Luis Cammy", 830 + img: "luis.png", 831 + opts: { 832 + branch: ['jp'], 833 + girl: true 834 + } 835 + }, 836 + { 837 + name: "Fuwa Minato", 838 + img: "minato.webp", 839 + opts: { 840 + branch: ['jp'], 841 + boy: true 842 + } 843 + }, 844 + { 845 + name: "Shirayuki Tomoe", 846 + img: "tomoe.webp", 847 + opts: { 848 + branch: ['jp'], 849 + girl: true 850 + } 851 + }, 852 + { 853 + name: "Gwelu Os Gar", 854 + img: "gwelu.webp", 855 + opts: { 856 + branch: ['jp'], 857 + boy: true 858 + } 859 + }, 860 + { 861 + name: "Mashiro", 862 + img: "mashiro.webp", 863 + opts: { 864 + branch: ['jp'], 865 + boy: true 866 + } 867 + }, 868 + { 869 + name: "Naraka", 870 + img: "naraka.png", 871 + opts: { 872 + branch: ['jp'], 873 + girl: true 874 + } 875 + }, 876 + { 877 + name: "Kurusu Natsume", 878 + img: "natsume.webp", 879 + opts: { 880 + branch: ['jp'], 881 + girl: true 882 + } 883 + }, 884 + { 885 + name: "Furen E Lustario", 886 + img: "furen.webp", 887 + opts: { 888 + branch: ['jp'], 889 + girl: true 890 + } 891 + }, 892 + { 893 + name: "Ibrahim", 894 + img: "ibrahim.webp", 895 + opts: { 896 + branch: ['jp'], 897 + boy: true 898 + } 899 + }, 900 + { 901 + name: "Melissa Kinrenka", 902 + img: "melissa.png", 903 + opts: { 904 + branch: ['jp'], 905 + retired: true 906 + } 907 + }, 908 + { 909 + name: "Nagao Kei", 910 + img: "kei.png", 911 + opts: { 912 + branch: ['jp'], 913 + boy: true 914 + } 915 + }, 916 + { 917 + name: "Genzuki Tojiro", 918 + img: "tojiro.png", 919 + opts: { 920 + branch: ['jp'], 921 + boy: true 922 + } 923 + }, 924 + { 925 + name: "Kaida Haru", 926 + img: "haru.webp", 927 + opts: { 928 + branch: ['jp'], 929 + boy: true 930 + } 931 + }, 932 + { 933 + name: "Kingyozaka Meiro", 934 + img: "meiro.png", 935 + opts: { 936 + branch: ['jp'], 937 + girl: true, 938 + retired: true 939 + } 940 + }, 941 + { 942 + name: "Sorahoshi Kirame", 943 + img: "kirame.webp", 944 + opts: { 945 + branch: ['jp'], 946 + girl: true 947 + } 948 + }, 949 + { 950 + name: "Asahina Akane", 951 + img: "mybini1.png", 952 + opts: { 953 + branch: ['jp'], 954 + girl: true, 955 + retired: true 956 + } 957 + }, 958 + { 959 + name: "Suo Sango", 960 + img: "sango.webp", 961 + opts: { 962 + branch: ['jp'], 963 + girl: true 964 + } 965 + }, 966 + { 967 + name: "Todo Kohaku", 968 + img: "kohaku.webp", 969 + opts: { 970 + branch: ['jp'], 971 + girl: true 972 + } 973 + }, 974 + { 975 + name: "Kitakoji Hisui", 976 + img: "hisui.png", 977 + opts: { 978 + branch: ['jp'], 979 + girl: true 980 + } 981 + }, 982 + { 983 + name: "Nishizono Chigusa", 984 + img: "chigusa.webp", 985 + opts: { 986 + branch: ['jp'], 987 + girl: true 988 + } 989 + }, 990 + { 991 + name: "Lain Paterson", 992 + img: "lain.webp", 993 + opts: { 994 + branch: ['jp'], 995 + girl: true 996 + } 997 + }, 998 + { 999 + name: "Axia Krone", 1000 + img: "axia.png", 1001 + opts: { 1002 + branch: ['jp'], 1003 + boy: true, 1004 + retired: true 1005 + } 1006 + }, 1007 + { 1008 + name: "Lauren Iroas", 1009 + img: "lauren.png", 1010 + opts: { 1011 + branch: ['jp'], 1012 + boy: true 1013 + } 1014 + }, 1015 + { 1016 + name: "Leos Vincent", 1017 + img: "leos.webp", 1018 + opts: { 1019 + branch: ['jp'], 1020 + boy: true 1021 + } 1022 + }, 1023 + { 1024 + name: "Oliver Evans", 1025 + img: "oliver.webp", 1026 + opts: { 1027 + branch: ['jp'], 1028 + boy: true 1029 + } 1030 + }, 1031 + { 1032 + name: "Hana Macchia", 1033 + img: "hana.webp", 1034 + opts: { 1035 + branch: ['id'], 1036 + girl: true 1037 + } 1038 + }, 1039 + { 1040 + name: "ZEA Cornelia", 1041 + img: "zea.png", 1042 + opts: { 1043 + branch: ['id'], 1044 + girl: true, 1045 + retired: true 1046 + } 1047 + }, 1048 + { 1049 + name: "Taka Radjiman", 1050 + img: "taka.png", 1051 + opts: { 1052 + branch: ['id'], 1053 + boy: true, 1054 + retired: true 1055 + } 1056 + }, 1057 + { 1058 + name: "Rai Galilei", 1059 + img: "rai.webp", 1060 + opts: { 1061 + branch: ['id'], 1062 + boy: true 1063 + } 1064 + }, 1065 + { 1066 + name: "Riksa Dhirendra", 1067 + img: "riksa.webp", 1068 + opts: { 1069 + branch: ['id'], 1070 + boy: true 1071 + } 1072 + }, 1073 + { 1074 + name: "Amicia Michella", 1075 + img: "amicia.png", 1076 + opts: { 1077 + branch: ['id'], 1078 + girl: true, 1079 + retired: true 1080 + } 1081 + }, 1082 + { 1083 + name: "Miyu Ottavia", 1084 + img: "miyu.png", 1085 + opts: { 1086 + branch: ['id'], 1087 + girl: true, 1088 + retired: true 1089 + } 1090 + }, 1091 + { 1092 + name: "Azura Cecillia", 1093 + img: "azura.png", 1094 + opts: { 1095 + branch: ['id'], 1096 + girl: true, 1097 + retired: true 1098 + } 1099 + }, 1100 + { 1101 + name: "Layla Alstroemeria", 1102 + img: "layla.webp", 1103 + opts: { 1104 + branch: ['id'], 1105 + girl: true 1106 + } 1107 + }, 1108 + { 1109 + name: "Nara Haramaung", 1110 + img: "nara.png", 1111 + opts: { 1112 + branch: ['id'], 1113 + girl: true, 1114 + retired: true 1115 + } 1116 + }, 1117 + { 1118 + name: "Etna Crimson", 1119 + img: "etna.webp", 1120 + opts: { 1121 + branch: ['id'], 1122 + girl: true 1123 + } 1124 + }, 1125 + { 1126 + name: "Bonnivier Pranaja", 1127 + img: "bonnivier.webp", 1128 + opts: { 1129 + branch: ['id'], 1130 + boy: true, 1131 + retired: true 1132 + } 1133 + }, 1134 + { 1135 + name: "Siska Leontyne", 1136 + img: "siska.png", 1137 + opts: { 1138 + branch: ['id'], 1139 + girl: true, 1140 + retired: true 1141 + } 1142 + }, 1143 + { 1144 + name: "Nagisa Arcinia", 1145 + img: "nagisa.webp", 1146 + opts: { 1147 + branch: ['id'], 1148 + girl: true 1149 + } 1150 + }, 1151 + { 1152 + name: "Derem Kado", 1153 + img: "derem.png", 1154 + opts: { 1155 + branch: ['id'], 1156 + girl: true 1157 + } 1158 + }, 1159 + { 1160 + name: "Reza Avanluna", 1161 + img: "reza.png", 1162 + opts: { 1163 + branch: ['id'], 1164 + boy: true, 1165 + retired: true 1166 + } 1167 + }, 1168 + { 1169 + name: "Hyona Elatiora", 1170 + img: "hyona.png", 1171 + opts: { 1172 + branch: ['id'], 1173 + girl: true, 1174 + retired: true 1175 + } 1176 + }, 1177 + { 1178 + name: "Xia Ekavira", 1179 + img: "xia.webp", 1180 + opts: { 1181 + branch: ['id'], 1182 + girl: true, 1183 + retired: true 1184 + } 1185 + }, 1186 + { 1187 + name: "Mika Melatika", 1188 + img: "mika.webp", 1189 + opts: { 1190 + branch: ['id'], 1191 + girl: true, 1192 + retired: true 1193 + } 1194 + }, 1195 + { 1196 + name: "Wiffy", 1197 + img: "wiffy.png", 1198 + opts: { 1199 + branch: ['kr'], 1200 + girl: true, 1201 + retired: true 1202 + } 1203 + }, 1204 + { 1205 + name: "Yu Ruri", 1206 + img: "ruri.png", 1207 + opts: { 1208 + branch: ['kr'], 1209 + girl: true, 1210 + retired: true 1211 + } 1212 + }, 1213 + { 1214 + name: "Shin Yuya", 1215 + img: "yuya.png", 1216 + opts: { 1217 + branch: ['kr'], 1218 + girl: true, 1219 + retired: true 1220 + } 1221 + }, 1222 + { 1223 + name: "Min Suha", 1224 + img: "suha.webp", 1225 + opts: { 1226 + branch: ['kr'], 1227 + boy: true 1228 + } 1229 + }, 1230 + { 1231 + name: "Moarin", 1232 + img: "moarin.png", 1233 + opts: { 1234 + branch: ['kr'], 1235 + girl: true, 1236 + retired: true 1237 + } 1238 + }, 1239 + { 1240 + name: "Gaon", 1241 + img: "gaon.png", 1242 + opts: { 1243 + branch: ['kr'], 1244 + boy: true 1245 + } 1246 + }, 1247 + { 1248 + name: "Kaen", 1249 + img: "kaen.png", 1250 + opts: { 1251 + branch: ['kr'], 1252 + boy: true, 1253 + retired: true 1254 + } 1255 + }, 1256 + { 1257 + name: "Lorou", 1258 + img: "lorou.png", 1259 + opts: { 1260 + branch: ['kr'], 1261 + boy: true, 1262 + retired: true 1263 + } 1264 + }, 1265 + { 1266 + name: "Han Chiho", 1267 + img: "chiho.png", 1268 + opts: { 1269 + branch: ['kr'], 1270 + boy: true, 1271 + retired: true 1272 + } 1273 + }, 1274 + { 1275 + name: "Hakuren", 1276 + img: "hakuren.png", 1277 + opts: { 1278 + branch: ['kr'], 1279 + boy: true, 1280 + retired: true 1281 + } 1282 + }, 1283 + { 1284 + name: "Lee Siu", 1285 + img: "siu.png", 1286 + opts: { 1287 + branch: ['kr'], 1288 + girl: true, 1289 + retired: true 1290 + } 1291 + }, 1292 + { 1293 + name: "So Nagi", 1294 + img: "nagi.png", 1295 + opts: { 1296 + branch: ['kr'], 1297 + girl: true 1298 + } 1299 + }, 1300 + { 1301 + name: "Chae Ara", 1302 + img: "ara.png", 1303 + opts: { 1304 + branch: ['kr'], 1305 + girl: true, 1306 + retired: true 1307 + } 1308 + }, 1309 + { 1310 + name: "Akira Ray", 1311 + img: "ray.webp", 1312 + opts: { 1313 + branch: ['kr'], 1314 + girl: true 1315 + } 1316 + }, 1317 + { 1318 + name: "Lee Roha", 1319 + img: "roha.webp", 1320 + opts: { 1321 + branch: ['kr'], 1322 + girl: true 1323 + } 1324 + }, 1325 + { 1326 + name: "Nun Bora", 1327 + img: "bora.png", 1328 + opts: { 1329 + branch: ['kr'], 1330 + girl: true, 1331 + retired: true 1332 + } 1333 + }, 1334 + { 1335 + name: "Oh Jiyu", 1336 + img: "jiyu.webp", 1337 + opts: { 1338 + branch: ['kr'], 1339 + girl: true 1340 + } 1341 + }, 1342 + { 1343 + name: "Shin Kiru", 1344 + img: "kiru.png", 1345 + opts: { 1346 + branch: ['kr'], 1347 + boy: true, 1348 + retired: true 1349 + } 1350 + }, 1351 + { 1352 + name: "Yang Nari", 1353 + img: "nari.webp", 1354 + opts: { 1355 + branch: ['kr'], 1356 + girl: true 1357 + } 1358 + }, 1359 + { 1360 + name: "Ryu Hari", 1361 + img: "hari.webp", 1362 + opts: { 1363 + branch: ['kr'], 1364 + girl: true 1365 + } 1366 + }, 1367 + { 1368 + name: "Seffyna", 1369 + img: "seffyna.png", 1370 + opts: { 1371 + branch: ['kr'], 1372 + girl: true 1373 + } 1374 + }, 1375 + { 1376 + name: "Ban Hada", 1377 + img: "hada.webp", 1378 + opts: { 1379 + branch: ['kr'], 1380 + girl: true, 1381 + retired: true 1382 + } 1383 + }, 1384 + { 1385 + name: "Song Mia", 1386 + img: "mia.png", 1387 + opts: { 1388 + branch: ['kr'], 1389 + girl: true, 1390 + retired: true 1391 + } 1392 + }, 1393 + { 1394 + name: "Elira Pendora", 1395 + img: "elira.webp", 1396 + opts: { 1397 + branch: ['en'], 1398 + girl: true 1399 + } 1400 + }, 1401 + { 1402 + name: "Pomu Rainpuff", 1403 + img: "pomu.webp", 1404 + opts: { 1405 + branch: ['en'], 1406 + girl: true, 1407 + retired: true 1408 + } 1409 + }, 1410 + { 1411 + name: "Finana Ryugu", 1412 + img: "finana.webp", 1413 + opts: { 1414 + branch: ['en'], 1415 + girl: true 1416 + } 1417 + }, 1418 + { 1419 + name: "Selen Tatsuki", 1420 + img: "selen.webp", 1421 + opts: { 1422 + branch: ['en'], 1423 + girl: true, 1424 + retired: true 1425 + } 1426 + }, 1427 + { 1428 + name: "Rosemi Lovelock", 1429 + img: "mybini2.webp", 1430 + opts: { 1431 + branch: ['en'], 1432 + girl: true, 1433 + retired: true 1434 + } 1435 + }, 1436 + { 1437 + name: "Petra Gurin", 1438 + img: "petra.webp", 1439 + opts: { 1440 + branch: ['en'], 1441 + girl: true 1442 + } 1443 + }, 1444 + { 1445 + name: "Nina Kosaka", 1446 + img: "nina.png", 1447 + opts: { 1448 + branch: ['en'], 1449 + girl: true, 1450 + retired: true 1451 + } 1452 + }, 1453 + { 1454 + name: "Millie Parfait", 1455 + img: "millie.webp", 1456 + opts: { 1457 + branch: ['en'], 1458 + girl: true 1459 + } 1460 + }, 1461 + { 1462 + name: "Enna Alouette", 1463 + img: "enna.webp", 1464 + opts: { 1465 + branch: ['en'], 1466 + girl: true 1467 + } 1468 + }, 1469 + { 1470 + name: "Reimu Endou", 1471 + img: "reimu.png", 1472 + opts: { 1473 + branch: ['en'], 1474 + girl: true, 1475 + retired: true 1476 + } 1477 + }, 1478 + { 1479 + name: "Luca Kaneshiro", 1480 + img: "luca.png", 1481 + opts: { 1482 + branch: ['en'], 1483 + boy: true 1484 + } 1485 + }, 1486 + { 1487 + name: "Shu Yamino", 1488 + img: "shu.png", 1489 + opts: { 1490 + branch: ['en'], 1491 + boy: true 1492 + } 1493 + }, 1494 + { 1495 + name: "Ike Eveland", 1496 + img: "ike.png", 1497 + opts: { 1498 + branch: ['en'], 1499 + boy: true, 1500 + retired: true 1501 + } 1502 + }, 1503 + { 1504 + name: "Mysta Rias", 1505 + img: "mysta.png", 1506 + opts: { 1507 + branch: ['en'], 1508 + boy: true, 1509 + retired: true 1510 + } 1511 + }, 1512 + { 1513 + name: "Vox Akuma", 1514 + img: "vox.png", 1515 + opts: { 1516 + branch: ['en'], 1517 + boy: true 1518 + } 1519 + }, 1520 + { 1521 + name: "Aadya", 1522 + img: "aadya.png", 1523 + opts: { 1524 + branch: ['in'], 1525 + girl: true, 1526 + retired: true 1527 + } 1528 + }, 1529 + { 1530 + name: "Noor", 1531 + img: "noor.png", 1532 + opts: { 1533 + branch: ['in'], 1534 + girl: true, 1535 + retired: true 1536 + } 1537 + }, 1538 + { 1539 + name: "Vihaan", 1540 + img: "vihaan.png", 1541 + opts: { 1542 + branch: ['in'], 1543 + boy: true, 1544 + retired: true 1545 + } 1546 + }, 1547 + { 1548 + name: "Ha Yun", 1549 + img: "yun.png", 1550 + opts: { 1551 + branch: ['kr'], 1552 + boy: true, 1553 + } 1554 + }, 1555 + { 1556 + name: "Na Sera", 1557 + img: "sera.webp", 1558 + opts: { 1559 + branch: ['kr'], 1560 + girl: true, 1561 + } 1562 + }, 1563 + { 1564 + name: "Ko Yami", 1565 + img: "ko.png", 1566 + opts: { 1567 + branch: ['kr'], 1568 + girl: true, 1569 + retired: true 1570 + } 1571 + }, 1572 + { 1573 + name: "Lee On", 1574 + img: "lee.png", 1575 + opts: { 1576 + branch: ['kr'], 1577 + girl: true, 1578 + retired: true 1579 + } 1580 + }, 1581 + { 1582 + name: "Umise Yotsuha", 1583 + img: "yotsuha.webp", 1584 + opts: { 1585 + branch: ['jp'], 1586 + girl: true, 1587 + } 1588 + }, 1589 + { 1590 + name: "Amagase Muyu", 1591 + img: "muyu.webp", 1592 + opts: { 1593 + branch: ['jp'], 1594 + girl: true 1595 + } 1596 + }, 1597 + { 1598 + name: "Ponto Nei", 1599 + img: "nei.png", 1600 + opts: { 1601 + branch: ['jp'], 1602 + girl: true 1603 + } 1604 + }, 1605 + { 1606 + name: "Hyakumantenbara Salome", 1607 + img: "salome.webp", 1608 + opts: { 1609 + branch: ['jp'], 1610 + girl: true 1611 + } 1612 + }, 1613 + { 1614 + name: "Sonny Brisko", 1615 + img: "sonny.webp", 1616 + opts: { 1617 + branch: ['en'], 1618 + boy: true 1619 + } 1620 + }, 1621 + { 1622 + name: "Uki Violeta", 1623 + img: "uki.webp", 1624 + opts: { 1625 + branch: ['en'], 1626 + boy: true 1627 + } 1628 + }, 1629 + { 1630 + name: "Alban Knox", 1631 + img: "alban.webp", 1632 + opts: { 1633 + branch: ['en'], 1634 + boy: true 1635 + } 1636 + }, 1637 + { 1638 + name: "Fulgur Ovid", 1639 + img: "fulgur.webp", 1640 + opts: { 1641 + branch: ['en'], 1642 + boy: true, 1643 + retired: true 1644 + } 1645 + }, 1646 + { 1647 + name: "Yugo Asuma", 1648 + img: "yugo.png", 1649 + opts: { 1650 + branch: ['en'], 1651 + boy: true, 1652 + retired: true 1653 + } 1654 + }, 1655 + { 1656 + name: "Shikinagi Akira", 1657 + img: "akira.webp", 1658 + opts: { 1659 + branch: ['jp'], 1660 + boy: true 1661 + } 1662 + }, 1663 + { 1664 + name: "Seraph Dazzlegarden", 1665 + img: "seraph.webp", 1666 + opts: { 1667 + branch: ['jp'], 1668 + boy: true 1669 + } 1670 + }, 1671 + { 1672 + name: "Fura Kanato", 1673 + img: "kanato.webp", 1674 + opts: { 1675 + branch: ['jp'], 1676 + boy: true 1677 + } 1678 + }, 1679 + { 1680 + name: "Watarai Hibari", 1681 + img: "hibari.webp", 1682 + opts: { 1683 + branch: ['jp'], 1684 + boy: true 1685 + } 1686 + }, 1687 + { 1688 + name: "Aia Amare", 1689 + img: "aia.webp", 1690 + opts: { 1691 + branch: ['en'], 1692 + girl: true 1693 + } 1694 + }, 1695 + { 1696 + name: "Kyo Kaneko", 1697 + img: "kyo.webp", 1698 + opts: { 1699 + branch: ['en'], 1700 + boy: true, 1701 + retired: true 1702 + } 1703 + }, 1704 + { 1705 + name: "Aster Arcadia", 1706 + img: "aster.webp", 1707 + opts: { 1708 + branch: ['en'], 1709 + boy: true, 1710 + retired: true 1711 + } 1712 + }, 1713 + { 1714 + name: "Maria Marionette", 1715 + img: "mybini3.webp", 1716 + opts: { 1717 + branch: ['en'], 1718 + girl: true 1719 + } 1720 + }, 1721 + { 1722 + name: "Ren Zotto", 1723 + img: "ren.webp", 1724 + opts: { 1725 + branch: ['en'], 1726 + boy: true 1727 + } 1728 + }, 1729 + { 1730 + name: "Scarle Yonaguni", 1731 + img: "scarle.webp", 1732 + opts: { 1733 + branch: ['en'], 1734 + girl: true 1735 + } 1736 + }, 1737 + { 1738 + name: "Kotoka Torahime", 1739 + img: "kotoka.png", 1740 + opts: { 1741 + branch: ['en'], 1742 + girl: true, 1743 + retired: true 1744 + } 1745 + }, 1746 + { 1747 + name: "Hex Haywire", 1748 + img: "hex.png", 1749 + opts: { 1750 + branch: ['en'], 1751 + boy: true, 1752 + retired: true 1753 + } 1754 + }, 1755 + { 1756 + name: "Doppio Dropscythe", 1757 + img: "doppio.png", 1758 + opts: { 1759 + branch: ['en'], 1760 + boy: true 1761 + } 1762 + }, 1763 + { 1764 + name: "Meloco Kyoran", 1765 + img: "meloco.png", 1766 + opts: { 1767 + branch: ['en'], 1768 + girl: true 1769 + } 1770 + }, 1771 + { 1772 + name: "Ver Vermillion", 1773 + img: "ver.png", 1774 + opts: { 1775 + branch: ['en'], 1776 + boy: true 1777 + } 1778 + }, 1779 + { 1780 + name: "Zaion LanZa", 1781 + img: "zaion.png", 1782 + opts: { 1783 + branch: ['en'], 1784 + girl: true, 1785 + retired: true 1786 + } 1787 + }, 1788 + { 1789 + name: "Igarashi Rika", 1790 + img: "rika.png", 1791 + opts: { 1792 + branch: ['jp'], 1793 + girl: true 1794 + } 1795 + }, 1796 + { 1797 + name: "Ishigami Nozomi", 1798 + img: "nozomi.png", 1799 + opts: { 1800 + branch: ['jp'], 1801 + girl: true 1802 + } 1803 + }, 1804 + { 1805 + name: "Kuramochi Meruto", 1806 + img: "mybini4.png", 1807 + opts: { 1808 + branch: ['jp'], 1809 + girl: true 1810 + } 1811 + }, 1812 + { 1813 + name: "Kaburaki Roco", 1814 + img: "roco.png", 1815 + opts: { 1816 + branch: ['jp'], 1817 + girl: true 1818 + } 1819 + }, 1820 + { 1821 + name: "Koshimizu Toru", 1822 + img: "toru.png", 1823 + opts: { 1824 + branch: ['jp'], 1825 + girl: true 1826 + } 1827 + }, 1828 + { 1829 + name: "Shishido Akari", 1830 + img: "akari.png", 1831 + opts: { 1832 + branch: ['jp'], 1833 + girl: true 1834 + } 1835 + }, 1836 + { 1837 + name: "Sophia Valentine", 1838 + img: "sophia.png", 1839 + opts: { 1840 + branch: ['jp'], 1841 + girl: true 1842 + } 1843 + }, 1844 + { 1845 + name: "Akagi Wen", 1846 + img: "wen.png", 1847 + opts: { 1848 + branch: ['jp'], 1849 + boy: true 1850 + } 1851 + }, 1852 + { 1853 + name: "Usami Rito", 1854 + img: "rito.png", 1855 + opts: { 1856 + branch: ['jp'], 1857 + boy: true 1858 + } 1859 + }, 1860 + { 1861 + name: "Saiki Ittetsu", 1862 + img: "ittetsu.png", 1863 + opts: { 1864 + branch: ['jp'], 1865 + boy: true 1866 + } 1867 + }, 1868 + { 1869 + name: "Hibachi Mana", 1870 + img: "mana.png", 1871 + opts: { 1872 + branch: ['jp'], 1873 + boy: true 1874 + } 1875 + }, 1876 + { 1877 + name: "Inami Rai", 1878 + img: "rai.png", 1879 + opts: { 1880 + branch: ['jp'], 1881 + boy: true 1882 + } 1883 + }, 1884 + { 1885 + name: "Koyanagi Rou", 1886 + img: "rou.png", 1887 + opts: { 1888 + branch: ['jp'], 1889 + boy: true 1890 + } 1891 + }, 1892 + { 1893 + name: "Hoshirube Sho", 1894 + img: "sho.png", 1895 + opts: { 1896 + branch: ['jp'], 1897 + boy: true 1898 + } 1899 + }, 1900 + { 1901 + name: "Murakumo Kagetsu", 1902 + img: "kagetsu.png", 1903 + opts: { 1904 + branch: ['jp'], 1905 + boy: true 1906 + } 1907 + }, 1908 + { 1909 + name: "Vantacrow Bringer", 1910 + img: "vanta.png", 1911 + opts: { 1912 + branch: ['en'], 1913 + boy: true 1914 + } 1915 + }, 1916 + { 1917 + name: "Yu Q. Wilson", 1918 + img: "wilson.png", 1919 + opts: { 1920 + branch: ['en'], 1921 + boy: true 1922 + } 1923 + }, 1924 + { 1925 + name: "Vezalius Bandage", 1926 + img: "vez.png", 1927 + opts: { 1928 + branch: ['en'], 1929 + boy: true 1930 + } 1931 + }, 1932 + { 1933 + name: "Claude Clawmark", 1934 + img: "claude.png", 1935 + opts: { 1936 + branch: ['en'], 1937 + boy: true 1938 + } 1939 + }, 1940 + { 1941 + name: "Kunai Nakasato", 1942 + img: "kunai.png", 1943 + opts: { 1944 + branch: ['en'], 1945 + girl: true, 1946 + retired: true 1947 + } 1948 + }, 1949 + { 1950 + name: "Victoria Brightshield", 1951 + img: "victoria.png", 1952 + opts: { 1953 + branch: ['en'], 1954 + girl: true, 1955 + retired: true 1956 + } 1957 + }, 1958 + { 1959 + name: "Shioriha Ruri", 1960 + img: "shioruri.png", 1961 + opts: { 1962 + branch: ['jp'], 1963 + girl: true 1964 + } 1965 + }, 1966 + { 1967 + name: "Tachisute Toto", 1968 + img: "toto.png", 1969 + opts: { 1970 + branch: ['jp'], 1971 + girl: true 1972 + } 1973 + }, 1974 + { 1975 + name: "Milan Kestrel", 1976 + img: "milan.png", 1977 + opts: { 1978 + branch: ['jp'], 1979 + boy: true 1980 + } 1981 + }, 1982 + { 1983 + name: "Kaisei", 1984 + img: "kaisei.png", 1985 + opts: { 1986 + branch: ['jp'], 1987 + boy: true 1988 + } 1989 + }, 1990 + { 1991 + name: "Kitami Yusei", 1992 + img: "yusei.png", 1993 + opts: { 1994 + branch: ['jp'], 1995 + boy: true 1996 + } 1997 + }, 1998 + { 1999 + name: "Sakaki Ness", 2000 + img: "ness.png", 2001 + opts: { 2002 + branch: ['jp'], 2003 + boy: true 2004 + } 2005 + }, 2006 + { 2007 + name: "Klara Charmwood", 2008 + img: "klara.png", 2009 + opts: { 2010 + branch: ['en'], 2011 + girl: true 2012 + } 2013 + }, 2014 + { 2015 + name: "Ryoma Barrenwort", 2016 + img: "ryoma.png", 2017 + opts: { 2018 + branch: ['en'], 2019 + boy: true 2020 + } 2021 + }, 2022 + { 2023 + name: "Twisty Amanozako", 2024 + img: "twisty.png", 2025 + opts: { 2026 + branch: ['en'], 2027 + girl: true, 2028 + retired: true 2029 + } 2030 + }, 2031 + { 2032 + name: "Kisara", 2033 + img: "kisara.png", 2034 + opts: { 2035 + branch: ['jp'], 2036 + girl: true 2037 + } 2038 + }, 2039 + { 2040 + name: "Kozue Mone", 2041 + img: "mone.png", 2042 + opts: { 2043 + branch: ['jp'], 2044 + girl: true 2045 + } 2046 + }, 2047 + { 2048 + name: "Shiga Riko", 2049 + img: "riko.png", 2050 + opts: { 2051 + branch: ['jp'], 2052 + girl: true 2053 + } 2054 + }, 2055 + { 2056 + name: "Tamanoi Nana", 2057 + img: "nana.png", 2058 + opts: { 2059 + branch: ['jp'], 2060 + girl: true 2061 + } 2062 + }, 2063 + { 2064 + name: "Lunlun", 2065 + img: "lunlun.png", 2066 + opts: { 2067 + branch: ['jp'] 2068 + } 2069 + }, 2070 + { 2071 + name: "Kirara Tamako", 2072 + img: "tamako.png", 2073 + opts: { 2074 + branch: ['jp'], 2075 + girl: true 2076 + } 2077 + }, 2078 + { 2079 + name: "Saotome Berry", 2080 + img: "berry.png", 2081 + opts: { 2082 + branch: ['jp'], 2083 + girl: true 2084 + } 2085 + }, 2086 + { 2087 + name: "Sakayori Soma", 2088 + img: "soma.png", 2089 + opts: { 2090 + branch: ['jp'], 2091 + boy: true 2092 + } 2093 + }, 2094 + { 2095 + name: "Nagisa Trout", 2096 + img: "nagisa.png", 2097 + opts: { 2098 + branch: ['jp'], 2099 + boy: true 2100 + } 2101 + }, 2102 + { 2103 + name: "Nanase Suzuna", 2104 + img: "suzuna.png", 2105 + opts: { 2106 + branch: ['jp'], 2107 + girl: true 2108 + } 2109 + }, 2110 + { 2111 + name: "Freodore", 2112 + img: "freodore.png", 2113 + opts: { 2114 + branch: ['en'], 2115 + boy: true 2116 + } 2117 + }, 2118 + { 2119 + name: "Kaelix Debonair", 2120 + img: "kaelix.png", 2121 + opts: { 2122 + branch: ['en'], 2123 + boy: true 2124 + } 2125 + }, 2126 + { 2127 + name: "Seible", 2128 + img: "seible.png", 2129 + opts: { 2130 + branch: ['en'], 2131 + boy: true 2132 + } 2133 + }, 2134 + { 2135 + name: "Zeal Ginjoka", 2136 + img: "zeal.png", 2137 + opts: { 2138 + branch: ['en'], 2139 + boy: true 2140 + } 2141 + }, 2142 + { 2143 + name: "Itsuki Sakyo", 2144 + img: "sakyo.png", 2145 + opts: { 2146 + branch: ['jp'], 2147 + boy: true 2148 + } 2149 + }, 2150 + { 2151 + name: "Hitotsubashi Ayato", 2152 + img: "ayato.png", 2153 + opts: { 2154 + branch: ['jp'], 2155 + boy: true 2156 + } 2157 + }, 2158 + { 2159 + name: "Kadou Mikaru", 2160 + img: "mikaru.png", 2161 + opts: { 2162 + branch: ['jp'], 2163 + girl: true 2164 + } 2165 + }, 2166 + { 2167 + name: "Togawa Nonoha", 2168 + img: "nonoha.png", 2169 + opts: { 2170 + branch: ['jp'], 2171 + girl: true 2172 + } 2173 + }, 2174 + { 2175 + name: "Nekoyashiki Miku", 2176 + img: "miku.png", 2177 + opts: { 2178 + branch: ['jp'], 2179 + girl: true 2180 + } 2181 + }, 2182 + { 2183 + name: "Yagyu Shino", 2184 + img: "shino.png", 2185 + opts: { 2186 + branch: ['jp'], 2187 + girl: true 2188 + } 2189 + }, 2190 + { 2191 + name: "Shinomiya Yuno", 2192 + img: "yuno.png", 2193 + opts: { 2194 + branch: ['jp'], 2195 + boy: true 2196 + } 2197 + }, 2198 + { 2199 + name: "Shirose Isumi", 2200 + img: "isumi.png", 2201 + opts: { 2202 + branch: ['jp'], 2203 + boy: true 2204 + } 2205 + }, 2206 + { 2207 + name: "Sumeragi Reo", 2208 + img: "reo.png", 2209 + opts: { 2210 + branch: ['jp'], 2211 + boy: true 2212 + } 2213 + }, 2214 + { 2215 + name: "Hanakago Tsubasa", 2216 + img: "tsubasa.png", 2217 + opts: { 2218 + branch: ['jp'], 2219 + boy: true 2220 + } 2221 + }, 2222 + { 2223 + name: "Shirasa Ayane", 2224 + img: "ayane.png", 2225 + opts: { 2226 + branch: ['jp'], 2227 + girl: true 2228 + } 2229 + }, 2230 + { 2231 + name: "Minamo Madoka", 2232 + img: "madoka.png", 2233 + opts: { 2234 + branch: ['jp'], 2235 + girl: true 2236 + } 2237 + }, 2238 + { 2239 + name: "Onotora", 2240 + img: "onotora.png", 2241 + opts: { 2242 + branch: ['jp'], 2243 + boy: true 2244 + } 2245 + }, 2246 + { 2247 + name: "Kuri Eita", 2248 + img: "eita.png", 2249 + opts: { 2250 + branch: ['jp'], 2251 + boy: true 2252 + } 2253 + }, 2254 + { 2255 + name: "Kokonami Iruka", 2256 + img: "iruka.png", 2257 + opts: { 2258 + branch: ['jp'], 2259 + girl: true 2260 + } 2261 + }, 2262 + { 2263 + name: "Senri Ayumu", 2264 + img: "ayumu.png", 2265 + opts: { 2266 + branch: ['jp'], 2267 + girl: true 2268 + } 2269 + }, 2270 + { 2271 + name: "Tsukahara Daichi", 2272 + img: "daichi.png", 2273 + opts: { 2274 + branch: ['jp'], 2275 + boy: true 2276 + } 2277 + }, 2278 + { 2279 + name: "Mikogami Kotone", 2280 + img: "kotone.png", 2281 + opts: { 2282 + branch: ['jp'], 2283 + girl: true 2284 + } 2285 + }, 2286 + { 2287 + name: "Reina", 2288 + img: "reina.png", 2289 + opts: { 2290 + branch: ['jp'], 2291 + girl: true 2292 + } 2293 + }, 2294 + { 2295 + name: "Rayon", 2296 + img: "rayon.png", 2297 + opts: { 2298 + branch: ['jp'], 2299 + boy: true 2300 + } 2301 + }, 2302 + ]
+184
src/filter.js
··· 1 + /** 2 + * Filter selection UI — renders a checklist in raw mode. 3 + * Returns a Promise that resolves to the optTaken array. 4 + */ 5 + 6 + 'use strict'; 7 + 8 + const readline = require('readline'); 9 + 10 + /** 11 + * @param {Array} options - Options array from the dataset 12 + * @returns {Promise<Array>} optTaken - mirrors the optTaken format in main.js 13 + */ 14 + function selectFilters(options) { 15 + return new Promise((resolve) => { 16 + // Build a flat list of items to render 17 + // Each item: { label, indent, groupIndex, subIndex, isGroup, checked, disabled, hasGroup } 18 + const items = []; 19 + 20 + options.forEach((opt, gi) => { 21 + if ('sub' in opt) { 22 + items.push({ 23 + label: opt.name, 24 + tooltip: opt.tooltip || '', 25 + isGroup: true, 26 + groupIndex: gi, 27 + subIndex: null, 28 + checked: opt.checked !== false, 29 + disabled: false, 30 + }); 31 + opt.sub.forEach((sub, si) => { 32 + items.push({ 33 + label: sub.name, 34 + tooltip: sub.tooltip || '', 35 + isGroup: false, 36 + groupIndex: gi, 37 + subIndex: si, 38 + checked: sub.checked !== false, 39 + disabled: opt.checked === false, 40 + }); 41 + }); 42 + } else { 43 + items.push({ 44 + label: opt.name, 45 + tooltip: opt.tooltip || '', 46 + isGroup: false, 47 + groupIndex: gi, 48 + subIndex: null, 49 + checked: opt.checked !== false, 50 + disabled: false, 51 + }); 52 + } 53 + }); 54 + 55 + let cursor = 0; 56 + // Move cursor to first non-disabled item 57 + while (items[cursor] && items[cursor].disabled) cursor++; 58 + 59 + // ── Terminal helpers ───────────────────────────────────────────────────── 60 + 61 + const ESC = '\x1b['; 62 + const RESET = '\x1b[0m'; 63 + const BOLD = '\x1b[1m'; 64 + const DIM = '\x1b[2m'; 65 + const INVERT = '\x1b[7m'; 66 + 67 + function clearScreen() { 68 + process.stdout.write('\x1b[2J\x1b[H'); 69 + } 70 + 71 + function render() { 72 + clearScreen(); 73 + process.stdout.write(BOLD + 'better-nijisort-cli' + RESET + '\n\n'); 74 + process.stdout.write(BOLD + 'Filter options' + RESET + '\n'); 75 + process.stdout.write(DIM + 'Use arrows / j k to move, space to toggle, enter to start\n' + RESET); 76 + process.stdout.write('\n'); 77 + 78 + items.forEach((item, idx) => { 79 + const isSelected = idx === cursor; 80 + const check = item.checked ? '[x]' : '[ ]'; 81 + const indent = item.isGroup ? '' : ' '; 82 + 83 + if (isSelected) { 84 + process.stdout.write('\x1b[7m' + indent + check + ' ' + item.label + RESET + '\n'); 85 + } else if (item.disabled) { 86 + process.stdout.write(DIM + indent + check + ' ' + item.label + RESET + '\n'); 87 + } else if (item.isGroup) { 88 + process.stdout.write(BOLD + indent + check + ' ' + item.label + RESET + '\n'); 89 + } else { 90 + process.stdout.write(indent + check + ' ' + item.label + '\n'); 91 + } 92 + 93 + // Blank line after the last sub-item of a group 94 + const next = items[idx + 1]; 95 + if (!item.isGroup && item.subIndex !== null && (!next || next.isGroup || next.subIndex === null)) { 96 + process.stdout.write('\n'); 97 + } 98 + }); 99 + 100 + process.stdout.write('\n'); 101 + } 102 + 103 + function toggleItem(idx) { 104 + const item = items[idx]; 105 + if (item.disabled) return; 106 + 107 + if (item.isGroup) { 108 + item.checked = !item.checked; 109 + // Enable/disable and reset sub-items 110 + items.forEach(i => { 111 + if (i.groupIndex === item.groupIndex && !i.isGroup) { 112 + i.disabled = !item.checked; 113 + if (item.checked) i.checked = true; 114 + } 115 + }); 116 + } else { 117 + item.checked = !item.checked; 118 + } 119 + } 120 + 121 + function moveCursor(dir) { 122 + let next = cursor + dir; 123 + while (next >= 0 && next < items.length && items[next].disabled) next += dir; 124 + if (next >= 0 && next < items.length) cursor = next; 125 + } 126 + 127 + function buildOptTaken() { 128 + // Reconstruct optTaken from items 129 + return options.map((opt, gi) => { 130 + if ('sub' in opt) { 131 + const groupItem = items.find(i => i.isGroup && i.groupIndex === gi); 132 + if (!groupItem.checked) return false; 133 + // Return array of booleans for sub-options 134 + return opt.sub.map((_, si) => { 135 + const subItem = items.find(i => !i.isGroup && i.groupIndex === gi && i.subIndex === si); 136 + return subItem ? subItem.checked : true; 137 + }); 138 + } else { 139 + const item = items.find(i => !i.isGroup && i.groupIndex === gi && i.subIndex === null); 140 + return item ? item.checked : false; 141 + } 142 + }); 143 + } 144 + 145 + // ── Input handling ─────────────────────────────────────────────────────── 146 + 147 + process.stdin.setRawMode(true); 148 + process.stdin.resume(); 149 + process.stdin.setEncoding('utf8'); 150 + 151 + render(); 152 + 153 + function onKey(key) { 154 + // Arrow keys arrive as a single escape sequence chunk 155 + if (key === '\x1b[A' || key === '\x1bOA') { moveCursor(-1); render(); return; } // Up 156 + if (key === '\x1b[B' || key === '\x1bOB') { moveCursor(1); render(); return; } // Down 157 + 158 + switch (key) { 159 + case 'k': moveCursor(-1); break; 160 + case 'j': moveCursor(1); break; 161 + case ' ': toggleItem(cursor); break; 162 + case '\r': 163 + case '\n': { 164 + process.stdin.setRawMode(false); 165 + process.stdin.pause(); 166 + process.stdin.removeListener('data', onKey); 167 + clearScreen(); 168 + resolve(buildOptTaken()); 169 + return; 170 + } 171 + case '\x03': // Ctrl-C 172 + process.stdin.setRawMode(false); 173 + process.exit(0); 174 + default: 175 + return; // ignore unknown keys without re-rendering 176 + } 177 + render(); 178 + } 179 + 180 + process.stdin.on('data', onKey); 181 + }); 182 + } 183 + 184 + module.exports = { selectFilters };
+158
src/graphics.js
··· 1 + /** 2 + * graphics.js — terminal image rendering 3 + * 4 + * Auto-detects supported protocol on first call (silently, no output to user): 5 + * 1. Kitty Graphics Protocol (TERM=xterm-kitty or KITTY_WINDOW_ID env) 6 + * 2. iTerm2 Inline Images Protocol (TERM_PROGRAM=iTerm.app or similar) 7 + * 3. Sixel (not implemented yet — treated as none) 8 + * 4. None / text-only fallback 9 + * 10 + * Public API: 11 + * supportsImages() → boolean 12 + * renderImage(imgPath, opts) → void (synchronous best-effort) 13 + */ 14 + 15 + 'use strict'; 16 + 17 + const fs = require('fs'); 18 + const path = require('path'); 19 + const { execFileSync } = require('child_process'); 20 + 21 + // ── Protocol detection ───────────────────────────────────────────────────── 22 + 23 + let _protocol = null; // 'kitty' | 'iterm2' | 'none' 24 + 25 + function detectProtocol() { 26 + if (_protocol !== null) return _protocol; 27 + 28 + const term = process.env.TERM || ''; 29 + const termProg = process.env.TERM_PROGRAM || ''; 30 + const kittyWin = process.env.KITTY_WINDOW_ID; 31 + 32 + if (term === 'xterm-kitty' || kittyWin !== undefined) { 33 + _protocol = 'kitty'; 34 + } else if ( 35 + termProg === 'iTerm.app' || 36 + termProg === 'WezTerm' || 37 + termProg === 'ghostty' 38 + ) { 39 + _protocol = 'iterm2'; 40 + } else { 41 + _protocol = 'none'; 42 + } 43 + 44 + return _protocol; 45 + } 46 + 47 + /** Returns true if the current terminal can display inline images. */ 48 + function supportsImages() { 49 + return detectProtocol() !== 'none'; 50 + } 51 + 52 + // ── Rendering helpers ────────────────────────────────────────────────────── 53 + 54 + /** 55 + * Kitty Graphics Protocol — base64 encoded image via APC escape. 56 + * Renders in a cell-bounded area using the kitty protocol "direct" mode. 57 + * @param {string} imgPath Absolute path to image file 58 + * @param {object} opts 59 + * @param {number} [opts.cols=20] Width in terminal columns 60 + * @param {number} [opts.rows=10] Height in terminal rows 61 + */ 62 + function renderKitty(imgPath, opts = {}) { 63 + const cols = opts.cols || 20; 64 + const rows = opts.rows || 10; 65 + 66 + let data; 67 + try { 68 + data = fs.readFileSync(imgPath); 69 + } catch { 70 + return; // missing image — skip silently 71 + } 72 + 73 + const b64 = data.toString('base64'); 74 + const chunk = 4096; 75 + let offset = 0; 76 + let first = true; 77 + 78 + // Use kitty's `icat` binary if available, otherwise raw protocol 79 + // Raw protocol is more compatible; icat requires kitty binary on PATH 80 + try { 81 + execFileSync('kitten', ['icat', 82 + '--align', 'left', 83 + '--place', `${cols}x${rows}@0x0`, 84 + imgPath 85 + ], { stdio: ['ignore', 'inherit', 'ignore'] }); 86 + return; 87 + } catch { /* fall through to raw protocol */ } 88 + 89 + // Raw kitty graphics protocol 90 + while (offset < b64.length) { 91 + const slice = b64.slice(offset, offset + chunk); 92 + offset += chunk; 93 + const more = offset < b64.length ? 1 : 0; 94 + 95 + let payload; 96 + if (first) { 97 + // a=T transmit+display, f=100 PNG/WebP, c=cols, r=rows 98 + payload = `a=T,f=100,c=${cols},r=${rows},m=${more};${slice}`; 99 + first = false; 100 + } else { 101 + payload = `m=${more};${slice}`; 102 + } 103 + 104 + process.stdout.write(`\x1b_G${payload}\x1b\\`); 105 + } 106 + process.stdout.write('\n'); 107 + } 108 + 109 + /** 110 + * iTerm2 Inline Images Protocol. 111 + * @param {string} imgPath 112 + * @param {object} opts 113 + * @param {number} [opts.width=200] Pixel width hint 114 + */ 115 + function renderIterm2(imgPath, opts = {}) { 116 + const width = opts.width || 200; 117 + let data; 118 + try { 119 + data = fs.readFileSync(imgPath); 120 + } catch { 121 + return; 122 + } 123 + const b64 = data.toString('base64'); 124 + const args = `inline=1;width=${width}px;preserveAspectRatio=1;name=${Buffer.from(path.basename(imgPath)).toString('base64')}`; 125 + process.stdout.write(`\x1b]1337;File=${args}:${b64}\x07\n`); 126 + } 127 + 128 + // ── Public API ───────────────────────────────────────────────────────────── 129 + 130 + /** 131 + * Render an image in the terminal if supported, otherwise no-op. 132 + * @param {string} imgPath Absolute path to image file 133 + * @param {object} [opts] 134 + * @param {number} [opts.cols=20] Kitty: width in columns 135 + * @param {number} [opts.rows=10] Kitty: height in rows 136 + * @param {number} [opts.width=200] iTerm2: pixel width 137 + */ 138 + function renderImage(imgPath, opts = {}) { 139 + if (!fs.existsSync(imgPath)) return; 140 + const proto = detectProtocol(); 141 + if (proto === 'kitty') renderKitty(imgPath, opts); 142 + if (proto === 'iterm2') renderIterm2(imgPath, opts); 143 + // 'none' → no-op 144 + } 145 + 146 + /** 147 + * Given a character's img filename (e.g. "mito.webp"), resolve the full 148 + * path under html/src/assets/liver/. 149 + * @param {string} imgFile 150 + * @returns {string} 151 + */ 152 + function resolveCharImage(imgFile) { 153 + return path.join( 154 + __dirname, '..', 'html', 'src', 'assets', 'liver', imgFile 155 + ); 156 + } 157 + 158 + module.exports = { supportsImages, renderImage, resolveCharImage };
+166
src/index.js
··· 1 + #!/usr/bin/env node 2 + /** 3 + * better-nijisort-cli — main entry point 4 + * Orchestrates: data load → filter select → sorting → results 5 + */ 6 + 7 + 'use strict'; 8 + 9 + const path = require('path'); 10 + const { loadLatestDataset } = require('./data'); 11 + const { selectFilters } = require('./filter'); 12 + const { Sorter } = require('./sorter'); 13 + const { showResults } = require('./result'); 14 + 15 + // ── ANSI helpers ───────────────────────────────────────────────────────────── 16 + const RESET = '\x1b[0m'; 17 + const BOLD = '\x1b[1m'; 18 + const DIM = '\x1b[2m'; 19 + const INVERT = '\x1b[7m'; 20 + 21 + function clearScreen() { 22 + process.stdout.write('\x1b[2J\x1b[H'); 23 + } 24 + 25 + /** 26 + * Pad a string to exactly `width` columns, truncating with '..' if too long. 27 + */ 28 + function padName(str, width) { 29 + if (str.length > width) return str.slice(0, width - 2) + '..'; 30 + return str.padEnd(width); 31 + } 32 + 33 + const MAX_WIDTH = 80; 34 + 35 + function renderSortingScreen(sorter) { 36 + clearScreen(); 37 + 38 + const cols = Math.min(process.stdout.columns || 80, MAX_WIDTH); 39 + const half = Math.floor((cols - 4) / 2); 40 + 41 + const { left, right } = sorter.getCurrentPair(); 42 + const pct = sorter.progress(); 43 + const bat = sorter.battleNo; 44 + 45 + // Title 46 + process.stdout.write(BOLD + 'better-nijisort-cli' + RESET + '\n\n'); 47 + 48 + // Progress bar 49 + const barWidth = cols - 2; 50 + const filled = Math.round(pct / 100 * barWidth); 51 + const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled); 52 + process.stdout.write(DIM + `Battle ${bat} ${bar} ${pct}%` + RESET + '\n\n'); 53 + 54 + // Names 55 + const leftName = padName(left.name, half); 56 + const rightName = padName(right.name, half); 57 + process.stdout.write(BOLD + leftName + RESET + ' ' + BOLD + rightName + RESET + '\n'); 58 + 59 + // Two blank lines 60 + process.stdout.write('\n\n'); 61 + 62 + // Keybinds 63 + const hint = DIM + 'h / \u2190 left l / \u2192 right k / \u2191 tie j / \u2193 undo' + RESET; 64 + process.stdout.write(hint + '\n'); 65 + } 66 + 67 + /** 68 + * Runs the interactive sorting session. 69 + * Returns results array when done. 70 + */ 71 + function runSorting(sorter) { 72 + return new Promise((resolve) => { 73 + renderSortingScreen(sorter); 74 + 75 + process.stdin.setRawMode(true); 76 + process.stdin.resume(); 77 + process.stdin.setEncoding('utf8'); 78 + 79 + let escBuf = ''; 80 + 81 + function onKey(key) { 82 + if (sorter.finished) return; 83 + 84 + // Arrow keys arrive as a single escape sequence chunk 85 + if (key === '\x1b[D' || key === '\x1bOD') { doAction('left'); return; } 86 + if (key === '\x1b[C' || key === '\x1bOC') { doAction('right'); return; } 87 + if (key === '\x1b[A' || key === '\x1bOA') { doAction('tie'); return; } 88 + if (key === '\x1b[B' || key === '\x1bOB') { doAction('undo'); return; } 89 + // Swallow other escape sequences 90 + if (key.startsWith('\x1b')) return; 91 + 92 + switch (key) { 93 + case 'h': doAction('left'); break; 94 + case 'l': doAction('right'); break; 95 + case 'k': doAction('tie'); break; 96 + case 'j': doAction('undo'); break; 97 + case '\x03': // Ctrl-C 98 + process.stdin.setRawMode(false); 99 + process.exit(0); 100 + } 101 + } 102 + 103 + function doAction(action) { 104 + if (action === 'undo') { 105 + sorter.undo(); 106 + renderSortingScreen(sorter); 107 + return; 108 + } 109 + 110 + const done = sorter.pick(action); 111 + if (done) { 112 + process.stdin.setRawMode(false); 113 + process.stdin.pause(); 114 + process.stdin.removeListener('data', onKey); 115 + clearScreen(); 116 + resolve(sorter.getResults()); 117 + } else { 118 + renderSortingScreen(sorter); 119 + } 120 + } 121 + 122 + process.stdin.on('data', onKey); 123 + }); 124 + } 125 + 126 + // ── Main ────────────────────────────────────────────────────────────────────── 127 + 128 + async function main() { 129 + let dataset; 130 + try { 131 + dataset = loadLatestDataset(); 132 + } catch (err) { 133 + console.error('Error loading dataset:', err.message); 134 + process.exit(1); 135 + } 136 + 137 + clearScreen(); 138 + process.stdout.write(BOLD + 'better-nijisort-cli' + RESET + DIM + ` (dataset: ${dataset.version})` + RESET + '\n\n'); 139 + 140 + // Step 1: Filter selection 141 + let optTaken; 142 + try { 143 + optTaken = await selectFilters(dataset.options); 144 + } catch (err) { 145 + console.error('Error during filter selection:', err.message); 146 + process.exit(1); 147 + } 148 + 149 + // Step 2: Build sorter (applies filters, initialises merge sort) 150 + let sorter; 151 + try { 152 + sorter = new Sorter(dataset.characterData, dataset.options, optTaken); 153 + } catch (err) { 154 + clearScreen(); 155 + console.error(err.message); 156 + process.exit(1); 157 + } 158 + 159 + // Step 3: Run sorting session 160 + const results = await runSorting(sorter); 161 + 162 + // Step 4: Show results 163 + await showResults(results); 164 + } 165 + 166 + main();
+114
src/result.js
··· 1 + /** 2 + * Result display — shows the sorted ranking table. 3 + * Supports dynamic top-N switching (5, 10, 20, 40) via keypress. 4 + */ 5 + 6 + 'use strict'; 7 + 8 + const TOP_OPTIONS = [5, 10, 20]; 9 + 10 + /** 11 + * Clears screen and renders the result table for a given top-N count. 12 + * @param {Array<{rank: number, name: string}>} results 13 + * @param {number} topN 14 + */ 15 + function renderResults(results, topN) { 16 + const RESET = '\x1b[0m'; 17 + const BOLD = '\x1b[1m'; 18 + const DIM = '\x1b[2m'; 19 + 20 + const MAX_WIDTH = 80; 21 + const tableW = Math.min(process.stdout.columns || 80, MAX_WIDTH); 22 + const twoCol = topN > 10; 23 + 24 + // All views share the same column sizing as the two-column layout: 25 + // each column is half the table width, rankW=4, nameW fills the rest. 26 + const colW = Math.floor(tableW / 2); 27 + const rankW = 4; 28 + const nameW = colW - rankW - 1; // -1 for separator space 29 + const divider = '\u2500'.repeat(tableW); 30 + 31 + const cell = (rank, name) => { 32 + const r = String(rank).padEnd(rankW); 33 + const n = name.length > nameW ? name.slice(0, nameW - 2) + '..' : name.padEnd(nameW); 34 + return r + ' ' + n; 35 + }; 36 + 37 + process.stdout.write('\x1b[2J\x1b[H'); 38 + process.stdout.write(BOLD + 'better-nijisort-cli' + RESET + '\n\n'); 39 + process.stdout.write(BOLD + `Top ${topN} results` + RESET + '\n'); 40 + process.stdout.write(DIM + divider + RESET + '\n'); 41 + 42 + // Header 43 + const hdr = 'rank'.padEnd(rankW) + ' ' + 'name'.padEnd(nameW); 44 + process.stdout.write(DIM + (twoCol ? hdr + ' ' + hdr : hdr) + RESET + '\n'); 45 + process.stdout.write(DIM + divider + RESET + '\n'); 46 + 47 + const slice = results.slice(0, topN); 48 + 49 + if (twoCol) { 50 + // Left col: indices 0..9, right col: indices 10..19 51 + const half = Math.ceil(topN / 2); 52 + for (let i = 0; i < half; i++) { 53 + const left = slice[i]; 54 + const right = slice[i + half]; 55 + const leftCell = left ? cell(left.rank, left.name) : ''.padEnd(colW - 1); 56 + const rightCell = right ? cell(right.rank, right.name) : ''; 57 + process.stdout.write(leftCell + ' ' + rightCell + '\n'); 58 + } 59 + } else { 60 + slice.forEach(({ rank, name }) => { 61 + process.stdout.write(cell(rank, name) + '\n'); 62 + }); 63 + } 64 + 65 + process.stdout.write('\n'); 66 + process.stdout.write(DIM + 'Switch view: '); 67 + TOP_OPTIONS.forEach((n, i) => { 68 + const active = n === topN; 69 + process.stdout.write((active ? RESET + BOLD : '') + `[${i + 1}] top ${n}` + RESET + DIM + ' '); 70 + }); 71 + process.stdout.write('\n' + 'Press q to quit.' + RESET + '\n'); 72 + } 73 + 74 + /** 75 + * Runs the interactive results screen. 76 + * @param {Array<{rank: number, name: string}>} results 77 + * @returns {Promise<void>} 78 + */ 79 + function showResults(results) { 80 + return new Promise((resolve) => { 81 + let currentTop = 10; 82 + renderResults(results, currentTop); 83 + 84 + process.stdin.setRawMode(true); 85 + process.stdin.resume(); 86 + process.stdin.setEncoding('utf8'); 87 + 88 + function onKey(key) { 89 + // Swallow escape sequences 90 + if (key.startsWith('\x1b')) return; 91 + 92 + switch (key) { 93 + case '1': currentTop = 5; break; 94 + case '2': currentTop = 10; break; 95 + case '3': currentTop = 20; break; 96 + case 'q': 97 + case '\x03': // Ctrl-C 98 + process.stdin.setRawMode(false); 99 + process.stdin.pause(); 100 + process.stdin.removeListener('data', onKey); 101 + process.stdout.write('\n'); 102 + resolve(); 103 + return; 104 + default: 105 + return; 106 + } 107 + renderResults(results, currentTop); 108 + } 109 + 110 + process.stdin.on('data', onKey); 111 + }); 112 + } 113 + 114 + module.exports = { showResults };
+273
src/sorter.js
··· 1 + /** 2 + * Merge sort logic — ported from html/src/js/main.js 3 + * Manages all state for an active sorting session. 4 + */ 5 + 6 + 'use strict'; 7 + 8 + class Sorter { 9 + constructor(characterData, options, optTaken) { 10 + // Filter characters based on selected options 11 + this.characterDataToSort = this._applyFilters(characterData.slice(), options, optTaken); 12 + 13 + if (this.characterDataToSort.length < 2) { 14 + throw new Error('Need at least 2 characters to sort. Adjust your filters.'); 15 + } 16 + 17 + // Shuffle with a timestamp seed (simple seeded shuffle) 18 + this.timestamp = Date.now(); 19 + this._shuffleSeeded(this.characterDataToSort, this.timestamp); 20 + 21 + // Merge sort state 22 + this.sortedIndexList = []; 23 + this.recordDataList = []; 24 + this.parentIndexList = []; 25 + this.tiedDataList = this.characterDataToSort.map(() => -1); 26 + this.totalBattles = 0; 27 + this.battleNo = 1; 28 + this.sortedNo = 0; 29 + this.pointer = 0; 30 + this.leftIndex = 0; 31 + this.leftInnerIndex = 0; 32 + this.rightIndex = 0; 33 + this.rightInnerIndex = 0; 34 + this.finished = false; 35 + 36 + // Undo snapshots 37 + this._prev = null; 38 + 39 + this._initMergeSort(); 40 + } 41 + 42 + // ── Private helpers ────────────────────────────────────────────────────────── 43 + 44 + _applyFilters(chars, options, optTaken) { 45 + options.forEach((opt, index) => { 46 + if ('sub' in opt) { 47 + if (optTaken[index]) { 48 + // optTaken[index] is a boolean array of which sub-options are checked 49 + const subArray = optTaken[index].reduce((list, bool, si) => { 50 + if (bool) list.push(opt.sub[si].key); 51 + return list; 52 + }, []); 53 + chars = chars.filter(char => { 54 + return opt.key in char.opts && char.opts[opt.key].some(k => subArray.includes(k)); 55 + }); 56 + } 57 + } else if (optTaken[index]) { 58 + chars = chars.filter(char => !char.opts[opt.key]); 59 + } 60 + }); 61 + return chars; 62 + } 63 + 64 + _shuffleSeeded(arr, seed) { 65 + // Simple seeded LCG pseudo-random for determinism 66 + let s = seed % 2147483647; 67 + if (s <= 0) s += 2147483646; 68 + const rand = () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; }; 69 + 70 + for (let i = arr.length - 1; i > 0; i--) { 71 + const j = Math.floor(rand() * (i + 1)); 72 + [arr[i], arr[j]] = [arr[j], arr[i]]; 73 + } 74 + } 75 + 76 + _initMergeSort() { 77 + const n = this.characterDataToSort.length; 78 + this.recordDataList = new Array(n).fill(0); 79 + this.sortedIndexList[0] = this.characterDataToSort.map((_, idx) => idx); 80 + this.parentIndexList[0] = -1; 81 + 82 + let midpoint = 0; 83 + let marker = 1; 84 + 85 + for (let i = 0; i < this.sortedIndexList.length; i++) { 86 + if (this.sortedIndexList[i].length > 1) { 87 + const parent = this.sortedIndexList[i]; 88 + midpoint = Math.ceil(parent.length / 2); 89 + 90 + this.sortedIndexList[marker] = parent.slice(0, midpoint); 91 + this.totalBattles += this.sortedIndexList[marker].length; 92 + this.parentIndexList[marker] = i; 93 + marker++; 94 + 95 + this.sortedIndexList[marker] = parent.slice(midpoint); 96 + this.totalBattles += this.sortedIndexList[marker].length; 97 + this.parentIndexList[marker] = i; 98 + marker++; 99 + } 100 + } 101 + 102 + this.leftIndex = this.sortedIndexList.length - 2; 103 + this.rightIndex = this.sortedIndexList.length - 1; 104 + this.leftInnerIndex = 0; 105 + this.rightInnerIndex = 0; 106 + } 107 + 108 + _saveSnapshot() { 109 + this._prev = { 110 + sortedIndexList: this.sortedIndexList.map(a => a.slice()), 111 + recordDataList: this.recordDataList.slice(), 112 + parentIndexList: this.parentIndexList.slice(), 113 + tiedDataList: this.tiedDataList.slice(), 114 + leftIndex: this.leftIndex, 115 + leftInnerIndex: this.leftInnerIndex, 116 + rightIndex: this.rightIndex, 117 + rightInnerIndex: this.rightInnerIndex, 118 + battleNo: this.battleNo, 119 + sortedNo: this.sortedNo, 120 + pointer: this.pointer, 121 + }; 122 + } 123 + 124 + _recordData(side) { 125 + if (side === 'left') { 126 + this.recordDataList[this.pointer] = this.sortedIndexList[this.leftIndex][this.leftInnerIndex]; 127 + this.leftInnerIndex++; 128 + } else { 129 + this.recordDataList[this.pointer] = this.sortedIndexList[this.rightIndex][this.rightInnerIndex]; 130 + this.rightInnerIndex++; 131 + } 132 + this.pointer++; 133 + this.sortedNo++; 134 + } 135 + 136 + _advanceState() { 137 + const leftListLen = this.sortedIndexList[this.leftIndex].length; 138 + const rightListLen = this.sortedIndexList[this.rightIndex].length; 139 + 140 + // Drain remaining left or right items 141 + if (this.leftInnerIndex < leftListLen && this.rightInnerIndex === rightListLen) { 142 + while (this.leftInnerIndex < leftListLen) this._recordData('left'); 143 + } else if (this.leftInnerIndex === leftListLen && this.rightInnerIndex < rightListLen) { 144 + while (this.rightInnerIndex < rightListLen) this._recordData('right'); 145 + } 146 + 147 + // If both sides exhausted, merge back into parent 148 + if (this.leftInnerIndex === leftListLen && this.rightInnerIndex === rightListLen) { 149 + for (let i = 0; i < leftListLen + rightListLen; i++) { 150 + this.sortedIndexList[this.parentIndexList[this.leftIndex]][i] = this.recordDataList[i]; 151 + } 152 + this.sortedIndexList.pop(); 153 + this.sortedIndexList.pop(); 154 + this.leftIndex -= 2; 155 + this.rightIndex -= 2; 156 + this.leftInnerIndex = 0; 157 + this.rightInnerIndex = 0; 158 + this.sortedIndexList.forEach((_, idx) => { this.recordDataList[idx] = 0; }); 159 + this.pointer = 0; 160 + } 161 + 162 + if (this.leftIndex < 0) { 163 + this.finished = true; 164 + this.timeTaken = Date.now() - this.timestamp; 165 + } else { 166 + this.battleNo++; 167 + } 168 + } 169 + 170 + // ── Public API ─────────────────────────────────────────────────────────────── 171 + 172 + /** Returns { left, right } character objects for the current battle. */ 173 + getCurrentPair() { 174 + const li = this.sortedIndexList[this.leftIndex][this.leftInnerIndex]; 175 + const ri = this.sortedIndexList[this.rightIndex][this.rightInnerIndex]; 176 + return { 177 + left: this.characterDataToSort[li], 178 + right: this.characterDataToSort[ri], 179 + }; 180 + } 181 + 182 + /** Progress percentage 0–100 */ 183 + progress() { 184 + return this.totalBattles > 0 ? Math.floor(this.sortedNo * 100 / this.totalBattles) : 0; 185 + } 186 + 187 + /** Pick 'left', 'right', or 'tie'. Returns true if sort is finished after this pick. */ 188 + pick(choice) { 189 + if (this.finished) return true; 190 + 191 + this._saveSnapshot(); 192 + 193 + switch (choice) { 194 + case 'left': { 195 + this._recordData('left'); 196 + while (this.tiedDataList[this.recordDataList[this.pointer - 1]] !== -1) this._recordData('left'); 197 + break; 198 + } 199 + case 'right': { 200 + this._recordData('right'); 201 + while (this.tiedDataList[this.recordDataList[this.pointer - 1]] !== -1) this._recordData('right'); 202 + break; 203 + } 204 + case 'tie': { 205 + this._recordData('left'); 206 + while (this.tiedDataList[this.recordDataList[this.pointer - 1]] !== -1) this._recordData('left'); 207 + // Link the right char as tied to the last recorded left char 208 + this.tiedDataList[this.recordDataList[this.pointer - 1]] = 209 + this.sortedIndexList[this.rightIndex][this.rightInnerIndex]; 210 + this._recordData('right'); 211 + while (this.tiedDataList[this.recordDataList[this.pointer - 1]] !== -1) this._recordData('right'); 212 + break; 213 + } 214 + default: 215 + return false; 216 + } 217 + 218 + this._advanceState(); 219 + return this.finished; 220 + } 221 + 222 + /** Undo the last pick. Returns false if nothing to undo. */ 223 + undo() { 224 + if (!this._prev) return false; 225 + const p = this._prev; 226 + this.sortedIndexList = p.sortedIndexList; 227 + this.recordDataList = p.recordDataList; 228 + this.parentIndexList = p.parentIndexList; 229 + this.tiedDataList = p.tiedDataList; 230 + this.leftIndex = p.leftIndex; 231 + this.leftInnerIndex = p.leftInnerIndex; 232 + this.rightIndex = p.rightIndex; 233 + this.rightInnerIndex = p.rightInnerIndex; 234 + this.battleNo = p.battleNo; 235 + this.sortedNo = p.sortedNo; 236 + this.pointer = p.pointer; 237 + this.finished = false; 238 + this._prev = null; 239 + return true; 240 + } 241 + 242 + /** 243 + * Returns an ordered array of { rank, name } objects after sorting is complete. 244 + * Tied characters share a rank. 245 + */ 246 + getResults() { 247 + if (!this.finished) return []; 248 + 249 + const finalIndexes = this.sortedIndexList[0].slice(); 250 + const results = []; 251 + let rankNum = 1; 252 + let tiedRankNum = 1; 253 + 254 + this.characterDataToSort.forEach((_, idx) => { 255 + const charIndex = finalIndexes[idx]; 256 + const character = this.characterDataToSort[charIndex]; 257 + results.push({ rank: rankNum, name: character.name }); 258 + 259 + if (idx < this.characterDataToSort.length - 1) { 260 + if (this.tiedDataList[charIndex] === finalIndexes[idx + 1]) { 261 + tiedRankNum++; 262 + } else { 263 + rankNum += tiedRankNum; 264 + tiedRankNum = 1; 265 + } 266 + } 267 + }); 268 + 269 + return results; 270 + } 271 + } 272 + 273 + module.exports = { Sorter };