TipitakaCatalogService.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. <?php
  2. namespace App\Services;
  3. use App\Facades\RedisCache;
  4. use App\Models\BookTitle;
  5. use App\Models\Channel;
  6. use App\Models\PaliText;
  7. use App\Models\ProgressChapter;
  8. use App\Models\TagMap;
  9. use Illuminate\Contracts\Pagination\LengthAwarePaginator;
  10. use Illuminate\Database\Query\Builder;
  11. use Illuminate\Support\Facades\DB;
  12. /**
  13. * library 三藏栏目的目录:分类树与「作品」。
  14. *
  15. * 作品 = book_titles 的一行,URL 里用 sn 作 id。作品名取起点段落的 pali_texts.toc;
  16. * book_titles.title 很多存的是丛书名(如 141-3 是 samantapāsādikā,实为 (SP) Cūḷavagga-aṭṭhakathā),
  17. * 只作 series 保留。作品的段落区间是
  18. * [paragraph, 同书下一条 book_titles.paragraph - 1],且不超过它所在 level-1 节点的末尾
  19. * (book 209 vajirabuddhi-ṭīkā 一个 level-1 节点里有 5 部作品)。
  20. *
  21. * 分类来自 public/data/category/default.json,id 是按文件顺序深度优先的流水号,
  22. * 与 HomeController::flattenWithIds() 编号一致(/library/tipitaka/category/28 = 小部)。
  23. * 作品归入「包含它所在 level-1 节点全部 tag」的最深一级分类。
  24. *
  25. * 某 channel 是否收录某作品,看作品起点 (book, paragraph) 在 progress_chapters 里有没有该
  26. * channel 的行;该行的 progress 就是整部作品的进度。
  27. *
  28. * TODO: 给 progress_chapters 加 (book, para) 索引(只加索引,不改列;migration 由用户决定,2026-11-04 定为以后做)。
  29. * 现在按作品根节点查版本只能全表扫描(本地 12.6 万行)。2026-11-04 在事务里临时建索引实测(测完回滚),
  30. * 各页 SQL 合计:三藏首页 132→14ms、分类「经藏」292→19ms、分类末级 76→9ms、译本列表 67→13ms、
  31. * 作品详情 17→5ms。受影响的查询:本类的 publicChannelsQuery()、workChannels(),以及
  32. * TipitakaController::versionsQuery()。
  33. * 顺带可考虑 pali_texts (level) 索引:buildWorks() 冷启动里 `where level = 1` 占 ~106ms(一天一次,不急)。
  34. */
  35. class TipitakaCatalogService
  36. {
  37. /**
  38. * 作品目录的缓存 key。分类 json 或 tag 有改动,等 TTL 过期,或手动 forget 这个 key。
  39. */
  40. public const CACHE_KEY = '/library/tipitaka/catalog/v2';
  41. private const CACHE_TTL = 86400;
  42. /** @var list<array{id: int, parent_id: int, name: string, tag: list<string>, level: int}>|null */
  43. private ?array $categories = null;
  44. /** @var list<array{sn: int, book: int, para: int, end: int, title: string, series: string, category_id: int|null, pcd_book_id: int|null, kind: string|null}>|null */
  45. private ?array $works = null;
  46. /** @var array<string, array<string, array>> channel uid => channelProgress() 的结果 */
  47. private array $progress = [];
  48. /** @var array<int, list<array>>|null parent_id => 子分类;分类树页对每个节点都要查,建一次索引 */
  49. private ?array $childrenIndex = null;
  50. /** @var array<int, list<int>> 分类 id => subtreeIds() 的结果 */
  51. private array $subtrees = [];
  52. /** @var array<int, list<array>> 分类 id => worksInCategory() 的结果 */
  53. private array $categoryWorks = [];
  54. /**
  55. * 封面渐变色池,按作品 sn 取余,保证同一作品颜色稳定。
  56. */
  57. private const COVER_GRADIENTS = [
  58. 'linear-gradient(160deg, #2d1020, #ae6b8b)',
  59. 'linear-gradient(160deg, #1a2d10, rgba(75, 114, 36, 0.61))',
  60. 'linear-gradient(160deg, #0d1f3c, rgb(55, 98, 150))',
  61. 'linear-gradient(160deg, #2d1020, rgb(151, 69, 94))',
  62. 'linear-gradient(160deg, #1a1a2d, rgb(76, 68, 146))',
  63. 'linear-gradient(160deg, #1a2820, rgb(55, 124, 99))',
  64. ];
  65. /**
  66. * channel 头像底色池,按 uid 取余,保证同一 channel 颜色稳定。
  67. */
  68. private const AVATAR_COLORS = ['#206bc4', '#7a3fc4', '#2fb344', '#c77700', '#d63939', '#0ca678', '#4263eb', '#ae3ec9'];
  69. public function __construct(protected TermService $termService) {}
  70. public static function channelColor(string $uid): string
  71. {
  72. return self::AVATAR_COLORS[hexdec(substr(md5($uid), 0, 4)) % count(self::AVATAR_COLORS)];
  73. }
  74. /**
  75. * 拍平的分类表,编号规则同 HomeController::flattenWithIds()。
  76. *
  77. * 不直接调那个静态方法:它的计数器是静态的,同一请求里调第二次编号就错位了。
  78. *
  79. * @return list<array{id: int, parent_id: int, name: string, tag: list<string>, level: int}>
  80. */
  81. public function categories(): array
  82. {
  83. if ($this->categories !== null) {
  84. return $this->categories;
  85. }
  86. $tree = json_decode(file_get_contents(public_path('data/category/default.json')), true);
  87. $flat = [];
  88. $nextId = 1;
  89. $walk = function (array $nodes, int $parentId, int $level) use (&$walk, &$flat, &$nextId): void {
  90. foreach ($nodes as $node) {
  91. $id = $nextId++;
  92. $flat[] = [
  93. 'id' => $id,
  94. 'parent_id' => $parentId,
  95. 'name' => $node['name'] ?? '',
  96. 'tag' => $node['tag'] ?? [],
  97. 'level' => $level,
  98. ];
  99. if (! empty($node['children']) && is_array($node['children'])) {
  100. $walk($node['children'], $id, $level + 1);
  101. }
  102. }
  103. };
  104. $walk($tree, 0, 1);
  105. return $this->categories = $flat;
  106. }
  107. /**
  108. * @return array{id: int, parent_id: int, name: string, tag: list<string>, level: int}|null
  109. */
  110. public function category(int $id): ?array
  111. {
  112. // 编号是从 1 开始的连续流水号,下标 = id - 1
  113. $category = $this->categories()[$id - 1] ?? null;
  114. return $category && $category['id'] === $id ? $category : null;
  115. }
  116. /**
  117. * @return list<array{id: int, parent_id: int, name: string, tag: list<string>, level: int}>
  118. */
  119. public function children(int $parentId): array
  120. {
  121. if ($this->childrenIndex === null) {
  122. $this->childrenIndex = [];
  123. foreach ($this->categories() as $c) {
  124. $this->childrenIndex[$c['parent_id']][] = $c;
  125. }
  126. }
  127. return $this->childrenIndex[$parentId] ?? [];
  128. }
  129. /**
  130. * 从顶级到该分类的路径(含自身)。
  131. *
  132. * @return list<array{id: int, parent_id: int, name: string, tag: list<string>, level: int}>
  133. */
  134. public function categoryPath(int $id): array
  135. {
  136. $path = [];
  137. $current = $this->category($id);
  138. while ($current) {
  139. array_unshift($path, $current);
  140. $current = $current['parent_id'] ? $this->category($current['parent_id']) : null;
  141. }
  142. return $path;
  143. }
  144. /**
  145. * 该分类自身及全部后代的 id。
  146. *
  147. * @return list<int>
  148. */
  149. public function subtreeIds(int $id): array
  150. {
  151. if (isset($this->subtrees[$id])) {
  152. return $this->subtrees[$id];
  153. }
  154. $ids = [$id];
  155. foreach ($this->children($id) as $child) {
  156. $ids = array_merge($ids, $this->subtreeIds($child['id']));
  157. }
  158. return $this->subtrees[$id] = $ids;
  159. }
  160. /**
  161. * 分类的本地化名字:查术语表,查不到用巴利名。
  162. *
  163. * @param list<array{name: string}> $categories
  164. * @return array<string, string> 巴利名 => 显示名
  165. */
  166. public function localNames(array $categories, string $lang): array
  167. {
  168. $names = array_values(array_unique(array_filter(array_column($categories, 'name'))));
  169. $map = array_combine($names, $names) ?: [];
  170. foreach ($this->termService->glossaryByLemma($names, $lang) ?? [] as $term) {
  171. if (! empty($term->meaning)) {
  172. $map[$term->word] = $term->meaning;
  173. }
  174. }
  175. return $map;
  176. }
  177. /**
  178. * 全部作品,按 sn 排序。
  179. *
  180. * @return list<array{sn: int, book: int, para: int, end: int, title: string, series: string, category_id: int|null, pcd_book_id: int|null, kind: string|null}>
  181. */
  182. public function works(): array
  183. {
  184. if ($this->works !== null) {
  185. return $this->works;
  186. }
  187. return $this->works = RedisCache::remember(self::CACHE_KEY, self::CACHE_TTL, fn () => $this->buildWorks());
  188. }
  189. /**
  190. * 按 sn 取作品。sn 有重复时(如 276)取 id 最小的那条。
  191. *
  192. * @return array{sn: int, book: int, para: int, end: int, title: string, series: string, category_id: int|null, pcd_book_id: int|null, kind: string|null}|null
  193. */
  194. public function work(int $sn): ?array
  195. {
  196. // FIXME: book_titles.sn 不唯一(276 同时是 214-3 与 214-2162),后一条按 sn 访问不到
  197. return collect($this->works())->firstWhere('sn', $sn);
  198. }
  199. /**
  200. * 段落所在的作品。
  201. *
  202. * @return array{sn: int, book: int, para: int, end: int, title: string, series: string, category_id: int|null, pcd_book_id: int|null, kind: string|null}|null
  203. */
  204. public function workAt(int $book, int $para): ?array
  205. {
  206. return collect($this->works())
  207. ->first(fn ($w) => $w['book'] === $book && $para >= $w['para'] && $para <= $w['end']);
  208. }
  209. /**
  210. * 分类(含后代)下的作品。
  211. *
  212. * @return list<array{sn: int, book: int, para: int, end: int, title: string, series: string, category_id: int|null, pcd_book_id: int|null, kind: string|null}>
  213. */
  214. public function worksInCategory(int $categoryId): array
  215. {
  216. if (isset($this->categoryWorks[$categoryId])) {
  217. return $this->categoryWorks[$categoryId];
  218. }
  219. $ids = array_flip($this->subtreeIds($categoryId));
  220. return $this->categoryWorks[$categoryId] = array_values(array_filter(
  221. $this->works(),
  222. fn ($w) => $w['category_id'] !== null && isset($ids[$w['category_id']])
  223. ));
  224. }
  225. /**
  226. * 某 channel 收录的作品及其进度,key 为 "book-para"(sn 有重复,不能当 key)。
  227. *
  228. * @return array<string, array{uid: string, progress: float, title: string|null, summary: string|null, updated_at: string|null}>
  229. */
  230. public function channelProgress(string $channelUid): array
  231. {
  232. return $this->progress[$channelUid] ??= $this->loadChannelProgress($channelUid);
  233. }
  234. /**
  235. * @return array<string, array{uid: string, progress: float, title: string|null, summary: string|null, updated_at: string|null}>
  236. */
  237. private function loadChannelProgress(string $channelUid): array
  238. {
  239. $roots = array_map(fn ($w) => [$w['book'], $w['para']], $this->works());
  240. if (empty($roots)) {
  241. return [];
  242. }
  243. return ProgressChapter::where('channel_id', $channelUid)
  244. ->whereIns(['book', 'para'], $roots)
  245. ->get(['uid', 'book', 'para', 'progress', 'title', 'summary', 'updated_at'])
  246. ->mapWithKeys(fn ($row) => ["{$row->book}-{$row->para}" => [
  247. 'uid' => (string) $row->uid,
  248. 'progress' => (float) $row->progress,
  249. 'title' => $row->title,
  250. 'summary' => $row->summary,
  251. 'updated_at' => $row->updated_at?->toDateString(),
  252. ]])
  253. ->all();
  254. }
  255. /**
  256. * 公开 channel 的列表(分页),每个带收录作品数与最近更新时间,按作品数降序。
  257. *
  258. * 在 SQL 里分页:线上 channel 很多,不能全取出来再切。
  259. *
  260. * @return LengthAwarePaginator<int, array{uid: string, name: string, type: string, lang: string, owner_uid: string, works: int, updated_at: string|null}>
  261. */
  262. public function publicChannels(?string $lang = null, int $perPage = 20, ?string $type = null): LengthAwarePaginator
  263. {
  264. $paginator = $this->publicChannelsQuery()
  265. ->when($lang, fn ($q) => $q->where('c.lang', $lang))
  266. ->when($type, fn ($q) => $q->where('c.type', $type))
  267. ->groupBy('c.uid', 'c.name', 'c.type', 'c.lang', 'c.owner_uid')
  268. ->orderByRaw('count(distinct pc.uid) desc')
  269. ->orderBy('c.uid')
  270. ->select([
  271. 'c.uid', 'c.name', 'c.type', 'c.lang', 'c.owner_uid',
  272. DB::raw('count(distinct pc.uid) as works'),
  273. DB::raw('max(pc.updated_at) as updated_at'),
  274. ])
  275. ->paginate($perPage);
  276. $paginator->setCollection($paginator->getCollection()->map(fn ($row) => [
  277. 'uid' => (string) $row->uid,
  278. 'name' => $row->name,
  279. 'type' => $row->type,
  280. 'lang' => $row->lang,
  281. 'owner_uid' => (string) $row->owner_uid,
  282. 'works' => (int) $row->works,
  283. 'updated_at' => $row->updated_at ? substr($row->updated_at, 0, 10) : null,
  284. ]));
  285. return $paginator;
  286. }
  287. /**
  288. * 公开 channel 按某一列(lang / type)计数,给过滤项用。可再按另一列过滤,
  289. * 这样选了语言后,类型的数量是该语言下的数量(反之亦然)。
  290. *
  291. * @param 'lang'|'type' $column
  292. * @param array<'lang'|'type', string|null> $filters
  293. * @return array<string, int> 值 => channel 数,按数量降序
  294. */
  295. public function publicChannelCounts(string $column, array $filters = []): array
  296. {
  297. return $this->publicChannelsQuery()
  298. ->when($filters['lang'] ?? null, fn ($q, $lang) => $q->where('c.lang', $lang))
  299. ->when($filters['type'] ?? null, fn ($q, $type) => $q->where('c.type', $type))
  300. ->groupBy("c.{$column}")
  301. ->orderByRaw('count(distinct c.uid) desc')
  302. ->selectRaw("c.{$column} as value, count(distinct c.uid) as n")
  303. ->pluck('n', 'value')
  304. ->map(fn ($n) => (int) $n)
  305. ->all();
  306. }
  307. /**
  308. * 收录了作品(作品根节点有 progress_chapters 行)的公开 channel。
  309. *
  310. * TODO: 缺 progress_chapters (book, para) 索引,join book_titles 时全表扫描,见类注释。
  311. */
  312. private function publicChannelsQuery(): Builder
  313. {
  314. return DB::table('progress_chapters as pc')
  315. ->join('book_titles as b', function ($join) {
  316. $join->on('b.book', '=', 'pc.book')->on('b.paragraph', '=', 'pc.para');
  317. })
  318. ->join('channels as c', 'c.uid', '=', 'pc.channel_id')
  319. ->where('c.status', 30);
  320. }
  321. /**
  322. * channel 分组顺序;没列出的 type 排在后面。
  323. */
  324. private const TYPE_ORDER = ['translation', 'original', 'nissaya', 'commentary'];
  325. /**
  326. * 把 channel 行按 type 分组(作品版本页用)。
  327. *
  328. * @param list<array{type: string}> $channels
  329. * @return list<array{type: string, label: string, channels: list<array>}>
  330. */
  331. public function groupByType(array $channels): array
  332. {
  333. $rank = fn (string $type) => array_search($type, self::TYPE_ORDER, true) === false ? 99 : array_search($type, self::TYPE_ORDER, true);
  334. return collect($channels)
  335. ->groupBy('type')
  336. ->sortBy(fn ($items, $type) => $rank($type))
  337. ->map(fn ($items, $type) => ['type' => $type, 'label' => __('labels.'.$type), 'channels' => $items->values()->all()])
  338. ->values()
  339. ->all();
  340. }
  341. /**
  342. * 过滤项:「全部」+ 按数量降序的各值。$langFile 决定标签翻译(language / labels)。
  343. *
  344. * @param array<string, int> $counts 值 => 数量
  345. * @return list<array{value: string, label: string, count: int}>
  346. */
  347. public function filterOptions(array $counts, string $langFile = 'language'): array
  348. {
  349. arsort($counts);
  350. return collect($counts)
  351. ->map(fn ($count, $value) => ['value' => $value, 'label' => __("{$langFile}.{$value}"), 'count' => $count])
  352. ->values()
  353. ->prepend(['value' => 'all', 'label' => __('library.all'), 'count' => array_sum($counts)])
  354. ->all();
  355. }
  356. /**
  357. * 收录了该作品的其他公开 channel。
  358. *
  359. * @return list<array{uid: string, name: string, lang: string, type: string, owner: mixed, progress: int, updated_at: string|null}>
  360. */
  361. public function workChannels(array $work, ?string $exceptChannel = null): array
  362. {
  363. return ProgressChapter::with('channel.owner')
  364. ->where('book', $work['book'])
  365. ->where('para', $work['para'])
  366. ->when($exceptChannel, fn ($q) => $q->where('channel_id', '<>', $exceptChannel))
  367. ->whereHas('channel', fn ($q) => $q->where('status', 30))
  368. ->orderByDesc('progress')
  369. ->get()
  370. ->map(fn ($row) => [
  371. 'uid' => (string) $row->channel_id,
  372. 'name' => $row->channel->name,
  373. 'lang' => $row->channel->lang,
  374. 'type' => $row->channel->type,
  375. 'owner' => $row->channel->owner,
  376. 'progress' => (int) round($row->progress * 100),
  377. 'updated_at' => $row->updated_at?->toDateString(),
  378. ])
  379. ->all();
  380. }
  381. /**
  382. * channel 的展示信息;不存在返回 null。
  383. *
  384. * 与阅读页一致,不限 status:用户自己的私有 channel 也能从 dashboard 点进来看。
  385. */
  386. public function channel(string $uid): ?Channel
  387. {
  388. return Channel::with('owner')->where('uid', $uid)->first();
  389. }
  390. /**
  391. * 作品的封面图。目前一律返回 null,页面用渐变色 + 书名作封面(coverGradient())。
  392. *
  393. * TODO: 以后从 S3 取封面(2026-11-06 定)。原来按 pcd_book_id 找本地
  394. * public/assets/images/cover/zh-hans/1/{id}.png,已停用;$work['pcd_book_id'] 仍在目录里,可作 S3 的 key。
  395. */
  396. public function coverUrl(array $work): ?string
  397. {
  398. return null;
  399. }
  400. public function coverGradient(array $work): string
  401. {
  402. return self::COVER_GRADIENTS[$work['sn'] % count(self::COVER_GRADIENTS)];
  403. }
  404. /**
  405. * 书卡片(x-ui.card-book)的数据:某 channel 的某部作品。
  406. *
  407. * @param array{uid: string, name: string, type: string, lang: string, owner?: mixed} $channel
  408. * @param array{progress: float, title: string|null, summary?: string|null}|null $progress progress_chapters 那一行
  409. * @return array<string, mixed>
  410. */
  411. public function bookCard(array $work, array $channel, ?array $progress): array
  412. {
  413. $title = ! empty($progress['title']) ? $progress['title'] : $work['title'];
  414. return [
  415. 'id' => $work['sn'],
  416. 'url' => route('library.tipitaka.channels.work', ['channel' => $channel['uid'], 'sn' => $work['sn']]),
  417. 'title' => $title,
  418. 'author' => $channel['name'],
  419. 'publisher' => $channel['owner'] ?? null,
  420. 'subTitle' => $work['kind'] ? __('library.'.$work['kind']) : null,
  421. 'type' => __('labels.'.$channel['type']),
  422. 'language' => __('language.'.$channel['lang']),
  423. 'cover' => $this->coverUrl($work),
  424. 'cover_gradient' => $this->coverGradient($work),
  425. 'description' => $progress['summary'] ?? '',
  426. 'progress' => $progress ? (int) round($progress['progress'] * 100) : null,
  427. ];
  428. }
  429. /**
  430. * @return list<array{sn: int, book: int, para: int, end: int, title: string, series: string, category_id: int|null, pcd_book_id: int|null, kind: string|null}>
  431. */
  432. private function buildWorks(): array
  433. {
  434. $level1 = PaliText::where('level', 1)
  435. ->orderBy('book')->orderBy('paragraph')
  436. ->get(['uid', 'book', 'paragraph', 'chapter_len', 'pcd_book_id']);
  437. // level-1 节点 uid => tag 名集合
  438. $tagsByUid = TagMap::query()
  439. ->join('tags', 'tags.id', '=', 'tag_maps.tag_id')
  440. ->whereIn('tag_maps.anchor_id', $level1->pluck('uid'))
  441. ->get(['tag_maps.anchor_id', 'tags.name'])
  442. ->groupBy('anchor_id')
  443. ->map(fn ($rows) => $rows->pluck('name')->all());
  444. // 深的分类排前面,下面取第一个命中的即为最深一级
  445. $categories = collect($this->categories())->filter(fn ($c) => ! empty($c['tag']))->sortByDesc('level')->values()->all();
  446. $titles = BookTitle::orderBy('sn')->orderBy('id')->get(['sn', 'book', 'paragraph', 'title']);
  447. // 先按书建索引:在 Eloquent 集合上逐条 where() 是 O(n²),冷启动要 3 秒
  448. $level1ByBook = [];
  449. foreach ($level1 as $node) {
  450. $level1ByBook[(int) $node->book][] = $node;
  451. }
  452. $titleParasByBook = [];
  453. foreach ($titles as $t) {
  454. $titleParasByBook[(int) $t->book][] = (int) $t->paragraph;
  455. }
  456. // 作品起点段落的目录标题 = 作品名
  457. $tocs = PaliText::query()
  458. ->whereIns(['book', 'paragraph'], $titles->map(fn ($t) => [$t->book, $t->paragraph])->all())
  459. ->get(['book', 'paragraph', 'toc'])
  460. ->mapWithKeys(fn ($p) => ["{$p->book}-{$p->paragraph}" => trim((string) $p->toc)]);
  461. $works = [];
  462. foreach ($titles as $title) {
  463. $book = (int) $title->book;
  464. $para = (int) $title->paragraph;
  465. // 所在 level-1 节点:同书里起点 ≤ 作品起点的最后一个(已按段落升序)
  466. $top = null;
  467. foreach ($level1ByBook[$book] ?? [] as $node) {
  468. if ($node->paragraph <= $para) {
  469. $top = $node;
  470. }
  471. }
  472. $end = $top ? $top->paragraph + $top->chapter_len - 1 : $para;
  473. $next = array_filter($titleParasByBook[$book] ?? [], fn ($p) => $p > $para);
  474. if ($next) {
  475. $end = min($end, min($next) - 1);
  476. }
  477. $tags = $top ? ($tagsByUid[(string) $top->uid] ?? []) : [];
  478. $best = null;
  479. if ($tags) {
  480. foreach ($categories as $category) {
  481. if (empty(array_diff($category['tag'], $tags))) {
  482. $best = $category;
  483. break;
  484. }
  485. }
  486. }
  487. $kind = collect(['pāḷi', 'aṭṭhakathā', 'ṭīkā'])->first(fn ($k) => in_array($k, $tags, true));
  488. $works[] = [
  489. 'sn' => (int) $title->sn,
  490. 'book' => $book,
  491. 'para' => $para,
  492. 'end' => (int) $end,
  493. 'title' => ($tocs["{$book}-{$para}"] ?? '') ?: trim($title->title),
  494. 'series' => trim($title->title),
  495. 'category_id' => $best['id'] ?? null,
  496. 'pcd_book_id' => $top?->pcd_book_id ? (int) $top->pcd_book_id : null,
  497. 'kind' => $kind,
  498. ];
  499. }
  500. return $works;
  501. }
  502. }