فهرست منبع

Merge pull request #2480 from visuddhinanda/development

Development
visuddhinanda 2 هفته پیش
والد
کامیت
3bcc2c24e4
80فایلهای تغییر یافته به همراه36218 افزوده شده و 78 حذف شده
  1. 5 0
      api-test/.gitignore
  2. 189 0
      api-test/README.md
  3. 20 0
      api-test/cases/01_system.php
  4. 44 0
      api-test/cases/02_search.php
  5. 115 0
      api-test/cases/03_reactions.php
  6. 22 0
      api-test/cases/04_progress.php
  7. 67 0
      api-test/cases/05_tipitaka.php
  8. 15 0
      api-test/client/.gitignore
  9. 23 0
      api-test/client/.openapi-generator-ignore
  10. 122 0
      api-test/client/.openapi-generator/FILES
  11. 1 0
      api-test/client/.openapi-generator/VERSION
  12. 39 0
      api-test/client/composer.json
  13. 463 0
      api-test/client/lib/Api/HeartbeatApi.php
  14. 1118 0
      api-test/client/lib/Api/MeApi.php
  15. 589 0
      api-test/client/lib/Api/ProgressApi.php
  16. 819 0
      api-test/client/lib/Api/ReactionsApi.php
  17. 1260 0
      api-test/client/lib/Api/SearchApi.php
  18. 529 0
      api-test/client/lib/Api/SearchSuggestApi.php
  19. 632 0
      api-test/client/lib/Api/TipitakaReadingApi.php
  20. 1216 0
      api-test/client/lib/Api/TipitakaRelatedParagraphsApi.php
  21. 449 0
      api-test/client/lib/Api/UpgradeApi.php
  22. 119 0
      api-test/client/lib/ApiException.php
  23. 613 0
      api-test/client/lib/Configuration.php
  24. 246 0
      api-test/client/lib/FormDataProcessor.php
  25. 273 0
      api-test/client/lib/HeaderSelector.php
  26. 409 0
      api-test/client/lib/Model/GetApiV3Heartbeat200Response.php
  27. 443 0
      api-test/client/lib/Model/GetApiV3Heartbeat200ResponseData.php
  28. 443 0
      api-test/client/lib/Model/GetApiV3MeReactions200Response.php
  29. 661 0
      api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInner.php
  30. 613 0
      api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInnerUser.php
  31. 443 0
      api-test/client/lib/Model/GetApiV3Progress200Response.php
  32. 702 0
      api-test/client/lib/Model/GetApiV3Progress200ResponseDataInner.php
  33. 443 0
      api-test/client/lib/Model/GetApiV3ReactionsTally200Response.php
  34. 518 0
      api-test/client/lib/Model/GetApiV3ReactionsTally200ResponseDataInner.php
  35. 443 0
      api-test/client/lib/Model/GetApiV3Search200Response.php
  36. 409 0
      api-test/client/lib/Model/GetApiV3SearchSearch200Response.php
  37. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200Response.php
  38. 477 0
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseDataInner.php
  39. 545 0
      api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseMeta.php
  40. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200Response.php
  41. 627 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInner.php
  42. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner.php
  43. 409 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMeta.php
  44. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200Response.php
  45. 409 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.php
  46. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.php
  47. 409 0
      api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.php
  48. 111 0
      api-test/client/lib/Model/ModelInterface.php
  49. 600 0
      api-test/client/lib/Model/PaginationMeta.php
  50. 409 0
      api-test/client/lib/Model/PostApiV3MeReactions200Response.php
  51. 602 0
      api-test/client/lib/Model/PostApiV3MeReactionsRequest.php
  52. 858 0
      api-test/client/lib/Model/PostApiV3SearchRequest.php
  53. 589 0
      api-test/client/lib/Model/ProblemDetails.php
  54. 603 0
      api-test/client/lib/ObjectSerializer.php
  55. 47 0
      api-test/config.php
  56. 26 0
      api-test/fixtures.php
  57. 124 0
      api-test/run.php
  58. 199 0
      api-test/src/bootstrap.php
  59. 169 0
      api-test/tools/filter-v3.php
  60. 54 0
      api-test/tools/regen.sh
  61. 4 4
      api-v13/app/Services/V3/TipitakaReadingService.php
  62. 151 16
      api-v13/documents/related-paragraphs-redesign.md
  63. 23 24
      api-v13/resources/views/pages/home.blade.php
  64. 3 0
      api-v13/scripts/related/.gitignore
  65. 300 0
      api-v13/scripts/related/align_chapters.php
  66. 151 0
      api-v13/scripts/related/apply_chapters.php
  67. 103 0
      api-v13/scripts/related/backup.php
  68. 3049 0
      api-v13/scripts/related/baseline.json
  69. 6374 0
      api-v13/scripts/related/book_config.json
  70. 264 0
      api-v13/scripts/related/build_book_config.php
  71. 129 0
      api-v13/scripts/related/build_markers.php
  72. 385 0
      api-v13/scripts/related/check.php
  73. 133 0
      api-v13/scripts/related/diff.php
  74. 316 0
      api-v13/scripts/related/export.php
  75. 390 0
      api-v13/scripts/related/known_issues.json
  76. 116 0
      api-v13/scripts/related/lib/Logger.php
  77. 202 0
      api-v13/scripts/related/lib/MarkerParser.php
  78. 85 0
      api-v13/scripts/related/lib/boot.php
  79. 6 0
      openapi/public/assets/protocol/main.yaml
  80. 37 34
      openapi/scripts/generate.php

+ 5 - 0
api-test/.gitignore

@@ -0,0 +1,5 @@
+# 依赖与可再生的构建产物(tools/regen.sh 会重生成),不入库
+/client/vendor/
+/client/composer.lock
+/spec/openapi.json
+/spec/openapi-v3.json

+ 189 - 0
api-test/README.md

@@ -0,0 +1,189 @@
+# api-test —— api-v13 的 `/api/v3` 契约测试
+
+用 openapi-generator 从 `openapi/public/assets/protocol/main.yaml` 生成 PHP(Guzzle)客户端,
+测试脚本是**纯 PHP**,用它来对 `/api/v3/*` 做契约冒烟 + 负向测试。**目前只测 v3 路由。**
+
+## 从零开始(全新环境)
+
+全新克隆后按顺序执行,到能跑出结果为止。
+
+### 0. 环境要求
+
+| 依赖 | 用途 | 版本 |
+| --- | --- | --- |
+| PHP | 跑测试脚本 | 8.1+(需 `ext-curl` / `ext-json` / `ext-mbstring`) |
+| Composer | 装 Guzzle 依赖 | 2.x |
+| Java | 仅「重新生成客户端」时用(openapi-generator 是 jar) | 17+ |
+| Node / npx | 仅「重新生成客户端」时用(redocly bundle) | 18+ |
+
+> 只跑测试、不重新生成客户端的话,Java / Node 不是必须的。
+
+### 1. 安装运行依赖(只需一次)
+
+生成客户端 `client/` 里只有代码,`vendor/` 被 gitignore 了,要先装 Guzzle:
+
+```bash
+cd api-test/client
+composer install --no-dev
+cd ../..
+```
+
+### 2. 运行测试
+
+```bash
+cd api-test
+php run.php --username=<username> --password=<password>            # 调 /v2/sign-in 换 token,跑全量
+php run.php --username=<username> --password=<password>  --server=staging
+php run.php --token=<bearer>                                # 或直接给 token
+```
+
+> 如需**重新生成客户端**(后端/规格改过之后),见下文「OpenAPI 修改后如何升级测试」。
+
+## 运行
+
+```bash
+cd api-test
+
+php run.php                          # 默认 local(http://127.0.0.1:8000/api)
+php run.php --server=staging         # 切服务器(alias)
+php run.php --server=https://foo/api # 任意完整 base URL
+php run.php --filter=search          # 只跑名字含 search 的用例
+php run.php --list                   # 列出可用服务器
+
+# 跑 /v3/me/*(需登录)用例 —— 两种给 token 的方式:
+php run.php --token=<bearer>                                   # 直接给 token
+php run.php --username=alice --password=secret                 # 调 /v2/sign-in 现换 token
+```
+
+### 可用 `--server` 一览
+
+| alias | URL | 说明 |
+| --- | --- | --- |
+| `local` | `http://127.0.0.1:8000/api` | 本地开发(默认) |
+| `staging` | `https://staging.wikipali.org/api` | 测试 |
+| `next` | `https://next.wikipali.org/api` | 生产预览 |
+| `prod` | `https://www.wikipali.org/api` | 生产 |
+| `next-cn` | `https://next.wikipali.cc/api` | 生产预览(中国) |
+| `prod-cn` | `https://www.wikipali.cc/api` | 生产(中国) |
+
+> 也可以 `--server=<完整 base URL>` 直连任意服务器(含 /api 前缀)。
+
+- 服务器切换优先级:`--server=` / `API_TEST_BASE` > `config.php` 的 `default_server`。
+- **鉴权优先级**:`--token=` > 环境变量 `API_TEST_TOKEN` > `--username/--password`
+  (或 `API_TEST_USERNAME`/`API_TEST_PASSWORD`)调 `POST /v2/sign-in` 登录换 token。
+  v3 只认 bearer token,**不用 username/password**——那两样只用于登录换 token。
+- **测试逻辑**:需要登录的 `/v3/me/reactions`(GET/POST/DELETE)各测两种情况——
+  无 token → 期望 401;带 token → 正常。带 token 的用例**缺 token 就 FAIL,不 SKIP**,
+  所以跑全量前要带 `--token=` 或 `--username/--password`。
+- **写操作在所有服务器上都真实执行**(POST 建 / DELETE 删,用垃圾 target_id,测完即删),
+  包括 prod。会产生少量垃圾数据,由测试账号定期清理。
+- 登录接口 `POST /v2/sign-in`:`username` 可以是用户名或邮箱;响应是 v2 信封
+  `{ok,message,data}`,**成败看 `ok` 字段**(失败时 HTTP 可能是 200 也可能是 400),
+  `data` 即 JWT。
+- 退出码:`0` 全绿;`1` 有失败/错误;`2` 参数/加载错误。
+
+## 目录结构
+
+```
+api-test/
+├── run.php               # 入口 + 运行器
+├── config.php            # 服务器 alias 与 token
+├── fixtures.php          # 种子数据(channel/target_id/book 等,按环境可能要改)
+├── src/bootstrap.php     # 断言/注册 helper(test/request/assert_*)
+├── cases/*.php           # 用例,按领域分文件
+├── spec/
+│   ├── openapi.json      # 全量 spec 的 bundle(构建输入)
+│   └── openapi-v3.json   # 切出的 v3 + 补丁(生成客户端的输入)
+├── tools/
+│   ├── filter-v3.php     # 切 /v3 + 修数组 items + 修 3 个漂移端点的响应 schema
+│   └── regen.sh          # 一键重新生成客户端
+└── client/               # openapi-generator 生成的 Guzzle 客户端(vendor/ 已 gitignore)
+```
+
+## OpenAPI 修改后如何升级测试
+
+后端改了路由 / 控制器 docblock / FormRequest / Resource 之后,spec 会过时,测试要跟着升
+两级:**先重生 spec,再重生客户端,最后人工核对用例**。客户端是自动生成的,但用例断言
+不会自动跟着变。
+
+### 1. 重新生成 OpenAPI spec(openapi 侧)
+
+```bash
+cd openapi
+php scripts/generate.php   # 从 api-v13 的 route:list + 控制器 AST 重新生成 main.yaml
+npm run lint               # redocly 校验,应为 0 error
+cd ..
+```
+
+> 前端类型也跟着变了的话,还要按根目录 CLAUDE.md 重跑
+> `npx openapi-typescript … -o dashboard-v6/src/api/schema.d.ts`(两份,mobile 那份别漏)。
+
+### 2. 重新生成测试客户端(api-test 侧)
+
+```bash
+# 首次先下载 openapi-generator-cli jar(7.25.0)
+mkdir -p /tmp/og
+curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.25.0/openapi-generator-cli-7.25.0.jar \
+  -o /tmp/og/openapi-generator-cli.jar
+
+# 一键:bundle main.yaml → 切 /v3 + 打补丁 → 生成 Guzzle 客户端 → composer install
+bash api-test/tools/regen.sh
+```
+
+`regen.sh` 内部四步,都对应 spec 的某一部分:
+
+| 步骤 | 做什么 | 产物 |
+| --- | --- | --- |
+| bundle | redocly 把 `main.yaml` 及所有 `$ref` 合并成单文件 | `spec/openapi.json` |
+| filter | `filter-v3.php` 切出 `/v3/*` + 修数组 items + 修 3 个漂移端点 schema | `spec/openapi-v3.json` |
+| generate | openapi-generator 生成 PHP(Guzzle) 客户端 | `client/`(会先 `rm -rf` 重来) |
+| install | composer 装 Guzzle | `client/vendor/` |
+
+> ⚠️ `client/` 是纯生成物,**不要手改**——`regen.sh` 会整个删掉重生成,手改会丢。
+
+### 3. 人工核对 / 更新用例(不会自动)
+
+客户端自动更新了,但 `cases/*.php`、`fixtures.php` 要人看:
+
+- **新增端点** → 在 `cases/` 加对应 `test()`(参照现有文件按领域放)。
+- **参数变了**(新增 required、改名、枚举增删)→ 改对应用例的入参。
+- **响应字段变了**(Resource 的 `@return` 改了形状)→ 改对应断言。
+- **种子数据失效** → `fixtures.php` 里的 `channel` / `target_id` / `search_doc_id` 换新值。
+
+### 4. 跑全量验证
+
+```bash
+php run.php --username=test161 --password=12345          # 默认 local
+php run.php --username=test161 --password=12345 --server=staging
+```
+
+- 如果新 spec 又冒出「spec 与实现不一致」导致客户端反序列化报错(如之前的
+  `Invalid array 'object[]'`),在 `filter-v3.php` 的 `applyDriftPatches()` 补,并同步更新
+  下面「已知漂移」表。
+- 退出码 `0` 才算绿;`1` 看每条 `❌ FAIL` 的详情。
+
+## 已知的 spec ↔ 实现漂移(记录在案,不是测试脚本的 bug)
+
+| 端点 | spec 说 | 实际 | 处理 |
+| --- | --- | --- | --- |
+| `GET/POST /v3/search` | 200 `{data:数组, meta}` + 非法 `search_mode` 422 | 200 `{success, data:{took,hits}}`;`search_mode` 不校验 | `filter-v3.php` 把 200 schema 修为 `{success,data:对象}`;用例只断言状态码 + `data` 键 |
+| `GET /v3/search-suggest` | 200 `{data:数组, meta}`;空 q 422 | 200 `{success, data:{query,suggestions}}`;空 q 400 `{success:false,error}` | 同上修 schema;空 q 用例按实际 400 断言 |
+| `GET /v3/upgrade` | 200 `{data:数组, meta}` | 200 `{data:{status:"ok"}}` | 修 schema 为 `{data:对象}` |
+
+根因是这三个控制器还没迁到 v3 的 Resource 信封(`{data}` 原生形状、错误体 RFC 9457
+Problem Details、成败只看状态码)。正确的长期修法在 api-v13 侧,这里只让测试客户端能用、
+把漂移显式记录。`filter-v3.php` 里的 `applyDriftPatches()` 就是这三处补丁,改对齐后可删。
+
+## 环境注意
+
+- **pspell / xdebug 启动警告**:本机 PHP CLI 的 pspell 扩展与 PHP 版本不匹配,每次 `php`
+  都会在 stderr 打警告,与测试无关,可忽略。
+- **staging 的 OpenSearch**:`/v3/search`、`/v3/search-suggest` 在 staging 会 500
+  (索引 mapping 与代码不一致:`resource_type` 缺 fielddata、`content.suggest.pali` 没映射)。
+  这是 staging 环境问题,本地跑这几条是绿的。
+- **种子数据按环境不同**:`fixtures.php` 里的 channel/target_id/doc_id 取自本地开发库,
+  换环境后可能需要更新。跨环境跑时,数据不存在通常只是返回空集(仍 200),不会假红,
+  但 `search/{id}` 的 200 用例依赖真实 doc id。
+- **写操作用垃圾数据 + 测完即删**:`/v3/me/reactions` 的增删查用例用固定垃圾
+  `target_id`(uuid 格式),DELETE 会清理;若中途失败留下残留,下次跑时幂等逻辑会
+  兜住(POST 返回 200 命中已有),最后由测试账号定期清理。

+ 20 - 0
api-test/cases/01_system.php

@@ -0,0 +1,20 @@
+<?php
+
+use \WikipaliApi\Api\HeartbeatApi;
+use \WikipaliApi\Api\UpgradeApi;
+
+test('GET /v3/heartbeat → 200 {data:{status,checked_at}}', function () {
+    $res = request(fn () => api(HeartbeatApi::class)->getApiV3HeartbeatWithHttpInfo());
+    assert_status(200, $res, 'heartbeat');
+    $data = $res['body']['data'] ?? null;
+    assert_has_key('status', $data, 'heartbeat.data');
+    assert_eq('ok', $data['status'], 'heartbeat.data.status');
+    assert_has_key('checked_at', $data, 'heartbeat.data');
+});
+
+test('GET /v3/upgrade → 200 {data:{status}}', function () {
+    // 注意:spec 把 data 写成 array,实际返回 {data:{status:"ok"}}(见 README 漂移说明)。
+    $res = request(fn () => api(UpgradeApi::class)->getApiV3UpgradeWithHttpInfo());
+    assert_status(200, $res, 'upgrade');
+    assert_has_key('data', $res['body'], 'upgrade');
+});

+ 44 - 0
api-test/cases/02_search.php

@@ -0,0 +1,44 @@
+<?php
+
+use \WikipaliApi\Api\SearchApi;
+use \WikipaliApi\Api\SearchSuggestApi;
+use \WikipaliApi\Model\PostApiV3SearchRequest;
+
+test('GET /v3/search?q=dukkha → 200', function () {
+    $res = request(fn () => api(SearchApi::class)->getApiV3SearchWithHttpInfo(fixtures()['search_q']));
+    assert_status(200, $res, 'search GET');
+    // spec 说 data 是数组,实际是 {success,data:{took,hits}}(漂移,见 README)
+    assert_has_key('data', $res['body'], 'search GET');
+});
+
+test('POST /v3/search → 200(与 GET 等价,参数在 body)', function () {
+    $body = new PostApiV3SearchRequest();
+    $body->setQ(fixtures()['search_q']);
+    $res = request(fn () => api(SearchApi::class)->postApiV3SearchWithHttpInfo($body));
+    assert_status(200, $res, 'search POST');
+    assert_has_key('data', $res['body'], 'search POST');
+});
+
+test('GET /v3/search/{id} → 200(真实文档 id)', function () {
+    $res = request(fn () => api(SearchApi::class)->getApiV3SearchSearchWithHttpInfo(fixtures()['search_doc_id']));
+    assert_status(200, $res, 'search show');
+    assert_has_key('data', $res['body'], 'search show');
+});
+
+test('GET /v3/search/{id} → 404(不存在的文档)', function () {
+    $res = request(fn () => api(SearchApi::class)->getApiV3SearchSearchWithHttpInfo('no_such_doc_xyz'));
+    assert_status(404, $res, 'search show 404');
+    assert_problem($res, 'search show 404');
+});
+
+test('GET /v3/search-suggest?q=dukkha → 200', function () {
+    $res = request(fn () => api(SearchSuggestApi::class)->getApiV3SearchSuggestWithHttpInfo(fixtures()['search_q']));
+    assert_status(200, $res, 'search-suggest');
+    assert_has_key('data', $res['body'], 'search-suggest');
+});
+
+test('GET /v3/search-suggest?q= → 400(空查询)', function () {
+    // 描述里写明「q 为空返回 400」;spec 只登记了 422,这里按实际 400 断言。
+    $res = raw('GET', '/v3/search-suggest', ['q' => '']);
+    assert_status(400, $res, 'search-suggest 空 q');
+});

+ 115 - 0
api-test/cases/03_reactions.php

@@ -0,0 +1,115 @@
+<?php
+
+use \WikipaliApi\Api\MeApi;
+use \WikipaliApi\Api\ReactionsApi;
+use \WikipaliApi\Model\PostApiV3MeReactionsRequest;
+
+/* ---- 公共 reactions / tally(只读,无需 token)---- */
+
+test('GET /v3/reactions?target_id=… → 200', function () {
+    $res = request(fn () => api(ReactionsApi::class)->getApiV3ReactionsWithHttpInfo(fixtures()['target_id']));
+    assert_status(200, $res, 'reactions');
+    assert_has_key('data', $res['body'], 'reactions');
+    assert_has_key('meta', $res['body'], 'reactions');
+});
+
+test('GET /v3/reactions(缺 target_id)→ 422', function () {
+    $res = raw('GET', '/v3/reactions');
+    assert_status(422, $res, 'reactions 缺 target_id');
+    assert_problem($res, 'reactions 缺 target_id');
+});
+
+test('GET /v3/reactions/tally?target_id=… → 200', function () {
+    $res = request(fn () => api(ReactionsApi::class)->getApiV3ReactionsTallyWithHttpInfo(fixtures()['target_id']));
+    assert_status(200, $res, 'tally');
+    assert_has_key('data', $res['body'], 'tally');
+    $first = $res['body']['data'][0] ?? null;
+    assert_has_key('type', $first, 'tally.data[0]');
+    assert_has_key('count', $first, 'tally.data[0]');
+});
+
+test('GET /v3/reactions/tally(缺 target_id)→ 422', function () {
+    $res = raw('GET', '/v3/reactions/tally');
+    assert_status(422, $res, 'tally 缺 target_id');
+    assert_problem($res, 'tally 缺 target_id');
+});
+
+/* ---- /v3/me/reactions:无 token → 401(每个动作各一条)---- */
+
+test('GET /v3/me/reactions(无 token)→ 401', function () {
+    $res = request(fn () => api(MeApi::class)->getApiV3MeReactionsWithHttpInfo());
+    assert_status(401, $res, 'me/reactions 无 token');
+    assert_problem($res, 'me/reactions 无 token');
+});
+
+test('POST /v3/me/reactions(无 token)→ 401', function () {
+    $body = new PostApiV3MeReactionsRequest();
+    $body->setType('watch');
+    $body->setTargetId('11111111-1111-4111-8111-111111111111');
+    $body->setTargetType('collection');
+    $res = request(fn () => api(MeApi::class)->postApiV3MeReactionsWithHttpInfo($body));
+    assert_status(401, $res, 'me/reactions POST 无 token');
+    assert_problem($res, 'me/reactions POST 无 token');
+});
+
+test('GET /v3/me/reactions(非法 token)→ 401', function () {
+    $res = request(fn () => api(MeApi::class, 'garbage-token')->getApiV3MeReactionsWithHttpInfo());
+    assert_status(401, $res, 'me/reactions 非法 token');
+    assert_problem($res, 'me/reactions 非法 token');
+});
+
+/* ---- /v3/me/reactions:带 token → 实际增删查(幂等 + 无 token 删 401 + 删后 404)---- */
+
+test('me/reactions 增删查:POST 建 → GET 查到 → 再 POST 幂等 → 无 token DELETE 401 → DELETE 204 → 再 DELETE 404', function () {
+    $token = required_token();
+
+    // 垃圾 target(uuid 格式即可,不要求真实存在),测完删掉
+    $targetId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
+    $body = new PostApiV3MeReactionsRequest();
+    $body->setType('watch');
+    $body->setTargetId($targetId);
+    $body->setTargetType('collection');
+
+    // 增:POST
+    $create = request(fn () => api(MeApi::class, $token)->postApiV3MeReactionsWithHttpInfo($body));
+    if (! in_array($create['status'], [200, 201], true)) {
+        fail('POST 创建:期望 201(新建)或 200(已存在),实际 ' . $create['status']);
+    }
+    $id = $create['body']['data']['id'] ?? null;
+    if (! $id) {
+        fail('POST 创建:应返回 reaction id');
+    }
+
+    // 查:GET 按 type 过滤,能查到刚建的(用 target_id 定位,因为列表没有 target_id 过滤参数)
+    $list = request(fn () => api(MeApi::class, $token)->getApiV3MeReactionsWithHttpInfo(200, 'watch'));
+    assert_status(200, $list, 'GET 列表');
+    $found = false;
+    foreach (($list['body']['data'] ?? []) as $row) {
+        if (($row['target_id'] ?? null) === $targetId) {
+            $found = true;
+            break;
+        }
+    }
+    if (! $found) {
+        fail('GET 列表:查不到刚建的 reaction(target_id=' . $targetId . ')');
+    }
+
+    // 幂等:再 POST 同一条 → 200,id 不变
+    $again = request(fn () => api(MeApi::class, $token)->postApiV3MeReactionsWithHttpInfo($body));
+    assert_status(200, $again, '再 POST 幂等');
+    assert_eq($id, $again['body']['data']['id'] ?? null, '幂等:两次 id 一致');
+
+    // 无 token DELETE → 401(此时该 reaction 存在,路由绑定成功 → 鉴权 401)
+    $noTokenDel = raw('DELETE', '/v3/me/reactions/' . $id);
+    assert_status(401, $noTokenDel, '无 token DELETE');
+    assert_problem($noTokenDel, '无 token DELETE');
+
+    // 删:DELETE → 204
+    $del = request(fn () => api(MeApi::class, $token)->deleteApiV3MeReactionsReactionWithHttpInfo($id));
+    assert_status(204, $del, 'DELETE');
+
+    // 再删:已删除 → 404
+    $delAgain = request(fn () => api(MeApi::class, $token)->deleteApiV3MeReactionsReactionWithHttpInfo($id));
+    assert_status(404, $delAgain, '再 DELETE');
+    assert_problem($delAgain, '再 DELETE');
+});

+ 22 - 0
api-test/cases/04_progress.php

@@ -0,0 +1,22 @@
+<?php
+
+use \WikipaliApi\Api\ProgressApi;
+
+test('GET /v3/progress?channels=… → 200', function () {
+    $res = request(fn () => api(ProgressApi::class)->getApiV3ProgressWithHttpInfo(fixtures()['channel']));
+    assert_status(200, $res, 'progress');
+    assert_has_key('data', $res['body'], 'progress');
+    assert_has_key('meta', $res['body'], 'progress');
+});
+
+test('GET /v3/progress(缺 channels)→ 422', function () {
+    $res = raw('GET', '/v3/progress');
+    assert_status(422, $res, 'progress 缺 channels');
+    assert_problem($res, 'progress 缺 channels');
+});
+
+test('GET /v3/progress?order=非法值 → 422', function () {
+    $res = raw('GET', '/v3/progress', ['channels' => fixtures()['channel'], 'order' => 'bogus']);
+    assert_status(422, $res, 'progress 非法 order');
+    assert_problem($res, 'progress 非法 order');
+});

+ 67 - 0
api-test/cases/05_tipitaka.php

@@ -0,0 +1,67 @@
+<?php
+
+use \WikipaliApi\Api\TipitakaReadingApi;
+use \WikipaliApi\Api\TipitakaRelatedParagraphsApi;
+
+test('GET /v3/tipitaka-reading/{channel} → 200(无过滤,取首块)', function () {
+    $res = request(fn () => api(TipitakaReadingApi::class)->getApiV3TipitakaReadingChannelWithHttpInfo(fixtures()['channel'], null, null, null, null, null, 2));
+    assert_status(200, $res, 'tipitaka-reading');
+    assert_has_key('data', $res['body'], 'tipitaka-reading');
+    assert_has_key('meta', $res['body'], 'tipitaka-reading');
+    // next_cursor 为 null 时(取完)生成模型会把它丢掉,这里只看非空字段。
+    assert_eq('para', $res['body']['meta']['page_size_unit'] ?? null, 'tipitaka-reading.meta.page_size_unit');
+});
+
+test('GET /v3/tipitaka-reading/{channel}?book=… → 200(带 total)', function () {
+    $res = request(fn () => api(TipitakaReadingApi::class)->getApiV3TipitakaReadingChannelWithHttpInfo(fixtures()['channel'], 65, null, null, null, null, 2));
+    assert_status(200, $res, 'tipitaka-reading book 过滤');
+    assert_has_key('total', $res['body']['meta'] ?? [], 'tipitaka-reading.meta.total');
+});
+
+test('GET /v3/tipitaka-reading/{非 uuid} → 404', function () {
+    $res = raw('GET', '/v3/tipitaka-reading/not-a-uuid');
+    assert_status(404, $res, 'tipitaka-reading 非 uuid');
+    assert_problem($res, 'tipitaka-reading 非 uuid');
+});
+
+test('GET /v3/tipitaka-related-paragraphs?book_name=…&cs_para=… → 200', function () {
+    $res = request(fn () => api(TipitakaRelatedParagraphsApi::class)->getApiV3TipitakaRelatedParagraphsWithHttpInfo(
+        null, null, fixtures()['book_name'], fixtures()['cs_para']
+    ));
+    assert_status(200, $res, 'related-paragraphs');
+    assert_has_key('data', $res['body'], 'related-paragraphs');
+    assert_has_key('count', $res['body']['meta'] ?? [], 'related-paragraphs.meta.count');
+});
+
+test('GET /v3/tipitaka-related-paragraphs(两组坐标互斥)→ 422', function () {
+    $res = raw('GET', '/v3/tipitaka-related-paragraphs', [
+        'book' => fixtures()['book'],
+        'para' => fixtures()['para'],
+        'book_name' => fixtures()['book_name'],
+        'cs_para' => fixtures()['cs_para'],
+    ]);
+    assert_status(422, $res, 'related-paragraphs 互斥');
+    assert_problem($res, 'related-paragraphs 互斥');
+});
+
+test('GET /v3/tipitaka-related-paragraphs(两组都没给)→ 422', function () {
+    $res = raw('GET', '/v3/tipitaka-related-paragraphs');
+    assert_status(422, $res, 'related-paragraphs 缺坐标');
+    assert_problem($res, 'related-paragraphs 缺坐标');
+});
+
+test('GET /v3/tipitaka-related-paragraphs/aggregate → 200', function () {
+    $res = request(fn () => api(TipitakaRelatedParagraphsApi::class)->getApiV3TipitakaRelatedParagraphsAggregateWithHttpInfo());
+    assert_status(200, $res, 'aggregate');
+    assert_has_key('data', $res['body'], 'aggregate');
+    $first = $res['body']['data'][0] ?? null;
+    assert_has_key('book_name', $first, 'aggregate.data[0]');
+});
+
+test('GET /v3/tipitaka-related-paragraphs/aggregate/{book_name} → 200', function () {
+    $res = request(fn () => api(TipitakaRelatedParagraphsApi::class)->getApiV3TipitakaRelatedParagraphsAggregateBookNameWithHttpInfo(fixtures()['book_name']));
+    assert_status(200, $res, 'aggregate/{book_name}');
+    assert_has_key('data', $res['body'], 'aggregate/{book_name}');
+    $first = $res['body']['data'][0] ?? null;
+    assert_has_key('cs_para', $first, 'aggregate.data[0]');
+});

+ 15 - 0
api-test/client/.gitignore

@@ -0,0 +1,15 @@
+# ref: https://github.com/github/gitignore/blob/master/Composer.gitignore
+
+composer.phar
+/vendor/
+
+# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
+# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
+# composer.lock
+
+# php-cs-fixer cache
+.php_cs.cache
+.php-cs-fixer.cache
+
+# PHPUnit cache
+.phpunit.result.cache

+ 23 - 0
api-test/client/.openapi-generator-ignore

@@ -0,0 +1,23 @@
+# OpenAPI Generator Ignore
+# Generated by openapi-generator https://github.com/openapitools/openapi-generator
+
+# Use this file to prevent files from being overwritten by the generator.
+# The patterns follow closely to .gitignore or .dockerignore.
+
+# As an example, the C# client generator defines ApiClient.cs.
+# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
+#ApiClient.cs
+
+# You can match any string of characters against a directory, file or extension with a single asterisk (*):
+#foo/*/qux
+# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
+
+# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
+#foo/**/qux
+# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
+
+# You can also negate patterns with an exclamation (!).
+# For example, you can ignore all files in a docs folder with the file extension .md:
+#docs/*.md
+# Then explicitly reverse the ignore rule for a single file:
+#!docs/README.md

+ 122 - 0
api-test/client/.openapi-generator/FILES

@@ -0,0 +1,122 @@
+.gitignore
+.openapi-generator-ignore
+.php-cs-fixer.dist.php
+.travis.yml
+README.md
+composer.json
+docs/Api/HeartbeatApi.md
+docs/Api/MeApi.md
+docs/Api/ProgressApi.md
+docs/Api/ReactionsApi.md
+docs/Api/SearchApi.md
+docs/Api/SearchSuggestApi.md
+docs/Api/TipitakaReadingApi.md
+docs/Api/TipitakaRelatedParagraphsApi.md
+docs/Api/UpgradeApi.md
+docs/Model/GetApiV3Heartbeat200Response.md
+docs/Model/GetApiV3Heartbeat200ResponseData.md
+docs/Model/GetApiV3MeReactions200Response.md
+docs/Model/GetApiV3MeReactions200ResponseDataInner.md
+docs/Model/GetApiV3MeReactions200ResponseDataInnerUser.md
+docs/Model/GetApiV3Progress200Response.md
+docs/Model/GetApiV3Progress200ResponseDataInner.md
+docs/Model/GetApiV3ReactionsTally200Response.md
+docs/Model/GetApiV3ReactionsTally200ResponseDataInner.md
+docs/Model/GetApiV3Search200Response.md
+docs/Model/GetApiV3SearchSearch200Response.md
+docs/Model/GetApiV3TipitakaReadingChannel200Response.md
+docs/Model/GetApiV3TipitakaReadingChannel200ResponseDataInner.md
+docs/Model/GetApiV3TipitakaReadingChannel200ResponseMeta.md
+docs/Model/GetApiV3TipitakaRelatedParagraphs200Response.md
+docs/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInner.md
+docs/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner.md
+docs/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMeta.md
+docs/Model/GetApiV3TipitakaRelatedParagraphsAggregate200Response.md
+docs/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.md
+docs/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.md
+docs/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.md
+docs/Model/PaginationMeta.md
+docs/Model/PostApiV3MeReactions200Response.md
+docs/Model/PostApiV3MeReactionsRequest.md
+docs/Model/PostApiV3SearchRequest.md
+docs/Model/ProblemDetails.md
+git_push.sh
+lib/Api/HeartbeatApi.php
+lib/Api/MeApi.php
+lib/Api/ProgressApi.php
+lib/Api/ReactionsApi.php
+lib/Api/SearchApi.php
+lib/Api/SearchSuggestApi.php
+lib/Api/TipitakaReadingApi.php
+lib/Api/TipitakaRelatedParagraphsApi.php
+lib/Api/UpgradeApi.php
+lib/ApiException.php
+lib/Configuration.php
+lib/FormDataProcessor.php
+lib/HeaderSelector.php
+lib/Model/GetApiV3Heartbeat200Response.php
+lib/Model/GetApiV3Heartbeat200ResponseData.php
+lib/Model/GetApiV3MeReactions200Response.php
+lib/Model/GetApiV3MeReactions200ResponseDataInner.php
+lib/Model/GetApiV3MeReactions200ResponseDataInnerUser.php
+lib/Model/GetApiV3Progress200Response.php
+lib/Model/GetApiV3Progress200ResponseDataInner.php
+lib/Model/GetApiV3ReactionsTally200Response.php
+lib/Model/GetApiV3ReactionsTally200ResponseDataInner.php
+lib/Model/GetApiV3Search200Response.php
+lib/Model/GetApiV3SearchSearch200Response.php
+lib/Model/GetApiV3TipitakaReadingChannel200Response.php
+lib/Model/GetApiV3TipitakaReadingChannel200ResponseDataInner.php
+lib/Model/GetApiV3TipitakaReadingChannel200ResponseMeta.php
+lib/Model/GetApiV3TipitakaRelatedParagraphs200Response.php
+lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInner.php
+lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner.php
+lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMeta.php
+lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200Response.php
+lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.php
+lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.php
+lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.php
+lib/Model/ModelInterface.php
+lib/Model/PaginationMeta.php
+lib/Model/PostApiV3MeReactions200Response.php
+lib/Model/PostApiV3MeReactionsRequest.php
+lib/Model/PostApiV3SearchRequest.php
+lib/Model/ProblemDetails.php
+lib/ObjectSerializer.php
+phpunit.xml.dist
+test/Api/HeartbeatApiTest.php
+test/Api/MeApiTest.php
+test/Api/ProgressApiTest.php
+test/Api/ReactionsApiTest.php
+test/Api/SearchApiTest.php
+test/Api/SearchSuggestApiTest.php
+test/Api/TipitakaReadingApiTest.php
+test/Api/TipitakaRelatedParagraphsApiTest.php
+test/Api/UpgradeApiTest.php
+test/Model/GetApiV3Heartbeat200ResponseDataTest.php
+test/Model/GetApiV3Heartbeat200ResponseTest.php
+test/Model/GetApiV3MeReactions200ResponseDataInnerTest.php
+test/Model/GetApiV3MeReactions200ResponseDataInnerUserTest.php
+test/Model/GetApiV3MeReactions200ResponseTest.php
+test/Model/GetApiV3Progress200ResponseDataInnerTest.php
+test/Model/GetApiV3Progress200ResponseTest.php
+test/Model/GetApiV3ReactionsTally200ResponseDataInnerTest.php
+test/Model/GetApiV3ReactionsTally200ResponseTest.php
+test/Model/GetApiV3Search200ResponseTest.php
+test/Model/GetApiV3SearchSearch200ResponseTest.php
+test/Model/GetApiV3TipitakaReadingChannel200ResponseDataInnerTest.php
+test/Model/GetApiV3TipitakaReadingChannel200ResponseMetaTest.php
+test/Model/GetApiV3TipitakaReadingChannel200ResponseTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInnerTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMetaTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphs200ResponseTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInnerTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInnerTest.php
+test/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseTest.php
+test/Model/PaginationMetaTest.php
+test/Model/PostApiV3MeReactions200ResponseTest.php
+test/Model/PostApiV3MeReactionsRequestTest.php
+test/Model/PostApiV3SearchRequestTest.php
+test/Model/ProblemDetailsTest.php

+ 1 - 0
api-test/client/.openapi-generator/VERSION

@@ -0,0 +1 @@
+7.25.0

+ 39 - 0
api-test/client/composer.json

@@ -0,0 +1,39 @@
+{
+    "version": "1.0.0",
+    "description": "International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。",
+    "keywords": [
+        "openapitools",
+        "openapi-generator",
+        "openapi",
+        "php",
+        "sdk",
+        "rest",
+        "api"
+    ],
+    "homepage": "https://openapi-generator.tech",
+    "license": "unlicense",
+    "authors": [
+        {
+            "name": "OpenAPI",
+            "homepage": "https://openapi-generator.tech"
+        }
+    ],
+    "require": {
+        "php": "^8.1",
+        "ext-curl": "*",
+        "ext-json": "*",
+        "ext-mbstring": "*",
+        "guzzlehttp/guzzle": "^7.3",
+        "guzzlehttp/psr7": "^1.7 || ^2.0"
+    },
+    "require-dev": {
+        "phpunit/phpunit": "^8.0 || ^9.0",
+        "friendsofphp/php-cs-fixer": "^3.5"
+    },
+    "autoload": {
+        "psr-4": { "WikipaliApi\\" : "lib/" }
+    },
+    "autoload-dev": {
+        "psr-4": { "WikipaliApi\\Test\\" : "test/" }
+    }
+}

+ 463 - 0
api-test/client/lib/Api/HeartbeatApi.php

@@ -0,0 +1,463 @@
+<?php
+/**
+ * HeartbeatApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * HeartbeatApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class HeartbeatApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3Heartbeat' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3Heartbeat
+     *
+     * 心跳
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Heartbeat'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3Heartbeat200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3Heartbeat(string $contentType = self::contentTypes['getApiV3Heartbeat'][0])
+    {
+        list($response) = $this->getApiV3HeartbeatWithHttpInfo($contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3HeartbeatWithHttpInfo
+     *
+     * 心跳
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Heartbeat'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3Heartbeat200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3HeartbeatWithHttpInfo(string $contentType = self::contentTypes['getApiV3Heartbeat'][0])
+    {
+        $request = $this->getApiV3HeartbeatRequest($contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3Heartbeat200Response',
+                        $request,
+                        $response,
+                    );
+                case 503:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3Heartbeat200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3Heartbeat200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 503:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3HeartbeatAsync
+     *
+     * 心跳
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Heartbeat'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3HeartbeatAsync(string $contentType = self::contentTypes['getApiV3Heartbeat'][0])
+    {
+        return $this->getApiV3HeartbeatAsyncWithHttpInfo($contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3HeartbeatAsyncWithHttpInfo
+     *
+     * 心跳
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Heartbeat'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3HeartbeatAsyncWithHttpInfo(string $contentType = self::contentTypes['getApiV3Heartbeat'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3Heartbeat200Response';
+        $request = $this->getApiV3HeartbeatRequest($contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3Heartbeat'
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Heartbeat'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3HeartbeatRequest(string $contentType = self::contentTypes['getApiV3Heartbeat'][0])
+    {
+
+
+        $resourcePath = '/v3/heartbeat';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 1118 - 0
api-test/client/lib/Api/MeApi.php

@@ -0,0 +1,1118 @@
+<?php
+/**
+ * MeApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * MeApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class MeApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'deleteApiV3MeReactionsReaction' => [
+            'application/json',
+        ],
+        'getApiV3MeReactions' => [
+            'application/json',
+        ],
+        'postApiV3MeReactions' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation deleteApiV3MeReactionsReaction
+     *
+     * 删除我自己的 reaction
+     *
+     * @param  string $reaction reaction 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['deleteApiV3MeReactionsReaction'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return void
+     */
+    public function deleteApiV3MeReactionsReaction($reaction, string $contentType = self::contentTypes['deleteApiV3MeReactionsReaction'][0])
+    {
+        $this->deleteApiV3MeReactionsReactionWithHttpInfo($reaction, $contentType);
+    }
+
+    /**
+     * Operation deleteApiV3MeReactionsReactionWithHttpInfo
+     *
+     * 删除我自己的 reaction
+     *
+     * @param  string $reaction reaction 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['deleteApiV3MeReactionsReaction'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of null, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function deleteApiV3MeReactionsReactionWithHttpInfo($reaction, string $contentType = self::contentTypes['deleteApiV3MeReactionsReaction'][0])
+    {
+        $request = $this->deleteApiV3MeReactionsReactionRequest($reaction, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            return [null, $statusCode, $response->getHeaders()];
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 401:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 403:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation deleteApiV3MeReactionsReactionAsync
+     *
+     * 删除我自己的 reaction
+     *
+     * @param  string $reaction reaction 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['deleteApiV3MeReactionsReaction'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function deleteApiV3MeReactionsReactionAsync($reaction, string $contentType = self::contentTypes['deleteApiV3MeReactionsReaction'][0])
+    {
+        return $this->deleteApiV3MeReactionsReactionAsyncWithHttpInfo($reaction, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation deleteApiV3MeReactionsReactionAsyncWithHttpInfo
+     *
+     * 删除我自己的 reaction
+     *
+     * @param  string $reaction reaction 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['deleteApiV3MeReactionsReaction'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function deleteApiV3MeReactionsReactionAsyncWithHttpInfo($reaction, string $contentType = self::contentTypes['deleteApiV3MeReactionsReaction'][0])
+    {
+        $returnType = '';
+        $request = $this->deleteApiV3MeReactionsReactionRequest($reaction, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    return [null, $response->getStatusCode(), $response->getHeaders()];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'deleteApiV3MeReactionsReaction'
+     *
+     * @param  string $reaction reaction 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['deleteApiV3MeReactionsReaction'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function deleteApiV3MeReactionsReactionRequest($reaction, string $contentType = self::contentTypes['deleteApiV3MeReactionsReaction'][0])
+    {
+
+        // verify the required parameter 'reaction' is set
+        if ($reaction === null || (is_array($reaction) && count($reaction) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $reaction when calling deleteApiV3MeReactionsReaction'
+            );
+        }
+
+
+        $resourcePath = '/v3/me/reactions/{reaction}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+        // path params
+        if ($reaction !== null) {
+            $resourcePath = str_replace(
+                '{reaction}',
+                ObjectSerializer::toPathValue($reaction),
+                $resourcePath
+            );
+        }
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+        // this endpoint requires Bearer authentication (access token)
+        if (!empty($this->config->getAccessToken())) {
+            $headers['Authorization'] = 'Bearer ' . $this->config->getAccessToken();
+        }
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'DELETE',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation getApiV3MeReactions
+     *
+     * 列出我自己的 reactions
+     *
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  string|null $target_type target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3MeReactions200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3MeReactions($per_page = 15, $type = null, $target_type = null, $page = 1, string $contentType = self::contentTypes['getApiV3MeReactions'][0])
+    {
+        list($response) = $this->getApiV3MeReactionsWithHttpInfo($per_page, $type, $target_type, $page, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3MeReactionsWithHttpInfo
+     *
+     * 列出我自己的 reactions
+     *
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  string|null $target_type target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3MeReactions200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3MeReactionsWithHttpInfo($per_page = 15, $type = null, $target_type = null, $page = 1, string $contentType = self::contentTypes['getApiV3MeReactions'][0])
+    {
+        $request = $this->getApiV3MeReactionsRequest($per_page, $type, $target_type, $page, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3MeReactions200Response',
+                        $request,
+                        $response,
+                    );
+                case 401:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3MeReactions200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3MeReactions200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 401:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3MeReactionsAsync
+     *
+     * 列出我自己的 reactions
+     *
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  string|null $target_type target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3MeReactionsAsync($per_page = 15, $type = null, $target_type = null, $page = 1, string $contentType = self::contentTypes['getApiV3MeReactions'][0])
+    {
+        return $this->getApiV3MeReactionsAsyncWithHttpInfo($per_page, $type, $target_type, $page, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3MeReactionsAsyncWithHttpInfo
+     *
+     * 列出我自己的 reactions
+     *
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  string|null $target_type target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3MeReactionsAsyncWithHttpInfo($per_page = 15, $type = null, $target_type = null, $page = 1, string $contentType = self::contentTypes['getApiV3MeReactions'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3MeReactions200Response';
+        $request = $this->getApiV3MeReactionsRequest($per_page, $type, $target_type, $page, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3MeReactions'
+     *
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  string|null $target_type target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3MeReactionsRequest($per_page = 15, $type = null, $target_type = null, $page = 1, string $contentType = self::contentTypes['getApiV3MeReactions'][0])
+    {
+
+
+
+
+
+
+        $resourcePath = '/v3/me/reactions';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $per_page,
+            'per_page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $type,
+            'type', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $target_type,
+            'target_type', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+        // this endpoint requires Bearer authentication (access token)
+        if (!empty($this->config->getAccessToken())) {
+            $headers['Authorization'] = 'Bearer ' . $this->config->getAccessToken();
+        }
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation postApiV3MeReactions
+     *
+     * 添加一条我自己的 reaction(幂等)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3MeReactionsRequest $post_api_v3_me_reactions_request post_api_v3_me_reactions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\PostApiV3MeReactions200Response|\WikipaliApi\Model\PostApiV3MeReactions200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails
+     */
+    public function postApiV3MeReactions($post_api_v3_me_reactions_request, string $contentType = self::contentTypes['postApiV3MeReactions'][0])
+    {
+        list($response) = $this->postApiV3MeReactionsWithHttpInfo($post_api_v3_me_reactions_request, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation postApiV3MeReactionsWithHttpInfo
+     *
+     * 添加一条我自己的 reaction(幂等)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3MeReactionsRequest $post_api_v3_me_reactions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\PostApiV3MeReactions200Response|\WikipaliApi\Model\PostApiV3MeReactions200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function postApiV3MeReactionsWithHttpInfo($post_api_v3_me_reactions_request, string $contentType = self::contentTypes['postApiV3MeReactions'][0])
+    {
+        $request = $this->postApiV3MeReactionsRequest($post_api_v3_me_reactions_request, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\PostApiV3MeReactions200Response',
+                        $request,
+                        $response,
+                    );
+                case 201:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\PostApiV3MeReactions200Response',
+                        $request,
+                        $response,
+                    );
+                case 401:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\PostApiV3MeReactions200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\PostApiV3MeReactions200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 201:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\PostApiV3MeReactions200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 401:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation postApiV3MeReactionsAsync
+     *
+     * 添加一条我自己的 reaction(幂等)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3MeReactionsRequest $post_api_v3_me_reactions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3MeReactionsAsync($post_api_v3_me_reactions_request, string $contentType = self::contentTypes['postApiV3MeReactions'][0])
+    {
+        return $this->postApiV3MeReactionsAsyncWithHttpInfo($post_api_v3_me_reactions_request, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation postApiV3MeReactionsAsyncWithHttpInfo
+     *
+     * 添加一条我自己的 reaction(幂等)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3MeReactionsRequest $post_api_v3_me_reactions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3MeReactionsAsyncWithHttpInfo($post_api_v3_me_reactions_request, string $contentType = self::contentTypes['postApiV3MeReactions'][0])
+    {
+        $returnType = '\WikipaliApi\Model\PostApiV3MeReactions200Response';
+        $request = $this->postApiV3MeReactionsRequest($post_api_v3_me_reactions_request, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'postApiV3MeReactions'
+     *
+     * @param  \WikipaliApi\Model\PostApiV3MeReactionsRequest $post_api_v3_me_reactions_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3MeReactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function postApiV3MeReactionsRequest($post_api_v3_me_reactions_request, string $contentType = self::contentTypes['postApiV3MeReactions'][0])
+    {
+
+        // verify the required parameter 'post_api_v3_me_reactions_request' is set
+        if ($post_api_v3_me_reactions_request === null || (is_array($post_api_v3_me_reactions_request) && count($post_api_v3_me_reactions_request) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $post_api_v3_me_reactions_request when calling postApiV3MeReactions'
+            );
+        }
+
+
+        $resourcePath = '/v3/me/reactions';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (isset($post_api_v3_me_reactions_request)) {
+            if (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the body
+                try {
+                    $httpBody = json_encode(ObjectSerializer::sanitizeForSerialization($post_api_v3_me_reactions_request), JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                $httpBody = $post_api_v3_me_reactions_request;
+            }
+        } elseif (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+        // this endpoint requires Bearer authentication (access token)
+        if (!empty($this->config->getAccessToken())) {
+            $headers['Authorization'] = 'Bearer ' . $this->config->getAccessToken();
+        }
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'POST',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 589 - 0
api-test/client/lib/Api/ProgressApi.php

@@ -0,0 +1,589 @@
+<?php
+/**
+ * ProgressApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * ProgressApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class ProgressApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3Progress' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3Progress
+     *
+     * 列出章节翻译进度
+     *
+     * @param  string $channels channel uid 列表,**下划线分隔**(不是逗号) (required)
+     * @param  int|null $level 只返回该层级及以上的章节,需联查 pali_texts (optional)
+     * @param  string|null $lang 按译文语言过滤 (optional)
+     * @param  int|null $book 按典籍 id 过滤 (optional)
+     * @param  string|null $order 排序字段,白名单内 (optional, default to 'completed_at')
+     * @param  string|null $dir 排序方向 (optional, default to 'desc')
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Progress'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3Progress200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3Progress($channels, $level = null, $lang = null, $book = null, $order = 'completed_at', $dir = 'desc', $page = 1, $per_page = 10, string $contentType = self::contentTypes['getApiV3Progress'][0])
+    {
+        list($response) = $this->getApiV3ProgressWithHttpInfo($channels, $level, $lang, $book, $order, $dir, $page, $per_page, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3ProgressWithHttpInfo
+     *
+     * 列出章节翻译进度
+     *
+     * @param  string $channels channel uid 列表,**下划线分隔**(不是逗号) (required)
+     * @param  int|null $level 只返回该层级及以上的章节,需联查 pali_texts (optional)
+     * @param  string|null $lang 按译文语言过滤 (optional)
+     * @param  int|null $book 按典籍 id 过滤 (optional)
+     * @param  string|null $order 排序字段,白名单内 (optional, default to 'completed_at')
+     * @param  string|null $dir 排序方向 (optional, default to 'desc')
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Progress'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3Progress200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3ProgressWithHttpInfo($channels, $level = null, $lang = null, $book = null, $order = 'completed_at', $dir = 'desc', $page = 1, $per_page = 10, string $contentType = self::contentTypes['getApiV3Progress'][0])
+    {
+        $request = $this->getApiV3ProgressRequest($channels, $level, $lang, $book, $order, $dir, $page, $per_page, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3Progress200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3Progress200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3Progress200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3ProgressAsync
+     *
+     * 列出章节翻译进度
+     *
+     * @param  string $channels channel uid 列表,**下划线分隔**(不是逗号) (required)
+     * @param  int|null $level 只返回该层级及以上的章节,需联查 pali_texts (optional)
+     * @param  string|null $lang 按译文语言过滤 (optional)
+     * @param  int|null $book 按典籍 id 过滤 (optional)
+     * @param  string|null $order 排序字段,白名单内 (optional, default to 'completed_at')
+     * @param  string|null $dir 排序方向 (optional, default to 'desc')
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Progress'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3ProgressAsync($channels, $level = null, $lang = null, $book = null, $order = 'completed_at', $dir = 'desc', $page = 1, $per_page = 10, string $contentType = self::contentTypes['getApiV3Progress'][0])
+    {
+        return $this->getApiV3ProgressAsyncWithHttpInfo($channels, $level, $lang, $book, $order, $dir, $page, $per_page, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3ProgressAsyncWithHttpInfo
+     *
+     * 列出章节翻译进度
+     *
+     * @param  string $channels channel uid 列表,**下划线分隔**(不是逗号) (required)
+     * @param  int|null $level 只返回该层级及以上的章节,需联查 pali_texts (optional)
+     * @param  string|null $lang 按译文语言过滤 (optional)
+     * @param  int|null $book 按典籍 id 过滤 (optional)
+     * @param  string|null $order 排序字段,白名单内 (optional, default to 'completed_at')
+     * @param  string|null $dir 排序方向 (optional, default to 'desc')
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Progress'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3ProgressAsyncWithHttpInfo($channels, $level = null, $lang = null, $book = null, $order = 'completed_at', $dir = 'desc', $page = 1, $per_page = 10, string $contentType = self::contentTypes['getApiV3Progress'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3Progress200Response';
+        $request = $this->getApiV3ProgressRequest($channels, $level, $lang, $book, $order, $dir, $page, $per_page, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3Progress'
+     *
+     * @param  string $channels channel uid 列表,**下划线分隔**(不是逗号) (required)
+     * @param  int|null $level 只返回该层级及以上的章节,需联查 pali_texts (optional)
+     * @param  string|null $lang 按译文语言过滤 (optional)
+     * @param  int|null $book 按典籍 id 过滤 (optional)
+     * @param  string|null $order 排序字段,白名单内 (optional, default to 'completed_at')
+     * @param  string|null $dir 排序方向 (optional, default to 'desc')
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Progress'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3ProgressRequest($channels, $level = null, $lang = null, $book = null, $order = 'completed_at', $dir = 'desc', $page = 1, $per_page = 10, string $contentType = self::contentTypes['getApiV3Progress'][0])
+    {
+
+        // verify the required parameter 'channels' is set
+        if ($channels === null || (is_array($channels) && count($channels) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $channels when calling getApiV3Progress'
+            );
+        }
+
+
+
+
+
+
+
+
+
+        $resourcePath = '/v3/progress';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $channels,
+            'channels', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            true // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $level,
+            'level', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $lang,
+            'lang', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book,
+            'book', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $order,
+            'order', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $dir,
+            'dir', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $per_page,
+            'per_page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 819 - 0
api-test/client/lib/Api/ReactionsApi.php

@@ -0,0 +1,819 @@
+<?php
+/**
+ * ReactionsApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * ReactionsApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class ReactionsApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3Reactions' => [
+            'application/json',
+        ],
+        'getApiV3ReactionsTally' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3Reactions
+     *
+     * 列出某个 target 下的 reactions
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Reactions'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3MeReactions200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3Reactions($target_id, $per_page = 15, $type = null, $page = 1, string $contentType = self::contentTypes['getApiV3Reactions'][0])
+    {
+        list($response) = $this->getApiV3ReactionsWithHttpInfo($target_id, $per_page, $type, $page, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3ReactionsWithHttpInfo
+     *
+     * 列出某个 target 下的 reactions
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Reactions'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3MeReactions200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3ReactionsWithHttpInfo($target_id, $per_page = 15, $type = null, $page = 1, string $contentType = self::contentTypes['getApiV3Reactions'][0])
+    {
+        $request = $this->getApiV3ReactionsRequest($target_id, $per_page, $type, $page, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3MeReactions200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3MeReactions200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3MeReactions200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3ReactionsAsync
+     *
+     * 列出某个 target 下的 reactions
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Reactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3ReactionsAsync($target_id, $per_page = 15, $type = null, $page = 1, string $contentType = self::contentTypes['getApiV3Reactions'][0])
+    {
+        return $this->getApiV3ReactionsAsyncWithHttpInfo($target_id, $per_page, $type, $page, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3ReactionsAsyncWithHttpInfo
+     *
+     * 列出某个 target 下的 reactions
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Reactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3ReactionsAsyncWithHttpInfo($target_id, $per_page = 15, $type = null, $page = 1, string $contentType = self::contentTypes['getApiV3Reactions'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3MeReactions200Response';
+        $request = $this->getApiV3ReactionsRequest($target_id, $per_page, $type, $page, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3Reactions'
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string|null $type 操作类型 (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Reactions'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3ReactionsRequest($target_id, $per_page = 15, $type = null, $page = 1, string $contentType = self::contentTypes['getApiV3Reactions'][0])
+    {
+
+        // verify the required parameter 'target_id' is set
+        if ($target_id === null || (is_array($target_id) && count($target_id) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $target_id when calling getApiV3Reactions'
+            );
+        }
+
+
+
+
+
+        $resourcePath = '/v3/reactions';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $per_page,
+            'per_page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $target_id,
+            'target_id', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            true // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $type,
+            'type', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation getApiV3ReactionsTally
+     *
+     * 统计某个 target 下各 type 的计数
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3ReactionsTally'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3ReactionsTally200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3ReactionsTally($target_id, string $contentType = self::contentTypes['getApiV3ReactionsTally'][0])
+    {
+        list($response) = $this->getApiV3ReactionsTallyWithHttpInfo($target_id, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3ReactionsTallyWithHttpInfo
+     *
+     * 统计某个 target 下各 type 的计数
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3ReactionsTally'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3ReactionsTally200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3ReactionsTallyWithHttpInfo($target_id, string $contentType = self::contentTypes['getApiV3ReactionsTally'][0])
+    {
+        $request = $this->getApiV3ReactionsTallyRequest($target_id, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3ReactionsTally200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3ReactionsTally200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3ReactionsTally200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3ReactionsTallyAsync
+     *
+     * 统计某个 target 下各 type 的计数
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3ReactionsTally'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3ReactionsTallyAsync($target_id, string $contentType = self::contentTypes['getApiV3ReactionsTally'][0])
+    {
+        return $this->getApiV3ReactionsTallyAsyncWithHttpInfo($target_id, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3ReactionsTallyAsyncWithHttpInfo
+     *
+     * 统计某个 target 下各 type 的计数
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3ReactionsTally'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3ReactionsTallyAsyncWithHttpInfo($target_id, string $contentType = self::contentTypes['getApiV3ReactionsTally'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3ReactionsTally200Response';
+        $request = $this->getApiV3ReactionsTallyRequest($target_id, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3ReactionsTally'
+     *
+     * @param  string $target_id target 的 uuid (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3ReactionsTally'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3ReactionsTallyRequest($target_id, string $contentType = self::contentTypes['getApiV3ReactionsTally'][0])
+    {
+
+        // verify the required parameter 'target_id' is set
+        if ($target_id === null || (is_array($target_id) && count($target_id) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $target_id when calling getApiV3ReactionsTally'
+            );
+        }
+
+
+        $resourcePath = '/v3/reactions/tally';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $target_id,
+            'target_id', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            true // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 1260 - 0
api-test/client/lib/Api/SearchApi.php

@@ -0,0 +1,1260 @@
+<?php
+/**
+ * SearchApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * SearchApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class SearchApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3Search' => [
+            'application/json',
+        ],
+        'getApiV3SearchSearch' => [
+            'application/json',
+        ],
+        'postApiV3Search' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3Search
+     *
+     * 全文检索
+     *
+     * @param  string $q 搜索关键词 (required)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $page_size 每页数量,上限 100 (optional, default to 20)
+     * @param  string|null $search_mode 检索模式 (optional, default to 'fuzzy')
+     * @param  string|null $resource_type 资源类型 (optional)
+     * @param  string|null $resource_id 限定某个资源 id (optional)
+     * @param  string|null $granularity 文档颗粒度 (optional)
+     * @param  string|null $language 语言 (optional)
+     * @param  string|null $category 文档分类,逗号分隔 (optional)
+     * @param  string|null $tags 标签过滤,逗号分隔 (optional)
+     * @param  string[]|null $page_refs 页码标记 (optional)
+     * @param  string[]|null $related_id 关联 id (optional)
+     * @param  string|null $author 按作者或译者过滤 (optional)
+     * @param  string|null $channel 按来源 channel 过滤 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3Search200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3Search($q, $page = 1, $page_size = 20, $search_mode = 'fuzzy', $resource_type = null, $resource_id = null, $granularity = null, $language = null, $category = null, $tags = null, $page_refs = null, $related_id = null, $author = null, $channel = null, string $contentType = self::contentTypes['getApiV3Search'][0])
+    {
+        list($response) = $this->getApiV3SearchWithHttpInfo($q, $page, $page_size, $search_mode, $resource_type, $resource_id, $granularity, $language, $category, $tags, $page_refs, $related_id, $author, $channel, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3SearchWithHttpInfo
+     *
+     * 全文检索
+     *
+     * @param  string $q 搜索关键词 (required)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $page_size 每页数量,上限 100 (optional, default to 20)
+     * @param  string|null $search_mode 检索模式 (optional, default to 'fuzzy')
+     * @param  string|null $resource_type 资源类型 (optional)
+     * @param  string|null $resource_id 限定某个资源 id (optional)
+     * @param  string|null $granularity 文档颗粒度 (optional)
+     * @param  string|null $language 语言 (optional)
+     * @param  string|null $category 文档分类,逗号分隔 (optional)
+     * @param  string|null $tags 标签过滤,逗号分隔 (optional)
+     * @param  string[]|null $page_refs 页码标记 (optional)
+     * @param  string[]|null $related_id 关联 id (optional)
+     * @param  string|null $author 按作者或译者过滤 (optional)
+     * @param  string|null $channel 按来源 channel 过滤 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3Search200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3SearchWithHttpInfo($q, $page = 1, $page_size = 20, $search_mode = 'fuzzy', $resource_type = null, $resource_id = null, $granularity = null, $language = null, $category = null, $tags = null, $page_refs = null, $related_id = null, $author = null, $channel = null, string $contentType = self::contentTypes['getApiV3Search'][0])
+    {
+        $request = $this->getApiV3SearchRequest($q, $page, $page_size, $search_mode, $resource_type, $resource_id, $granularity, $language, $category, $tags, $page_refs, $related_id, $author, $channel, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3Search200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3Search200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3Search200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3SearchAsync
+     *
+     * 全文检索
+     *
+     * @param  string $q 搜索关键词 (required)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $page_size 每页数量,上限 100 (optional, default to 20)
+     * @param  string|null $search_mode 检索模式 (optional, default to 'fuzzy')
+     * @param  string|null $resource_type 资源类型 (optional)
+     * @param  string|null $resource_id 限定某个资源 id (optional)
+     * @param  string|null $granularity 文档颗粒度 (optional)
+     * @param  string|null $language 语言 (optional)
+     * @param  string|null $category 文档分类,逗号分隔 (optional)
+     * @param  string|null $tags 标签过滤,逗号分隔 (optional)
+     * @param  string[]|null $page_refs 页码标记 (optional)
+     * @param  string[]|null $related_id 关联 id (optional)
+     * @param  string|null $author 按作者或译者过滤 (optional)
+     * @param  string|null $channel 按来源 channel 过滤 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3SearchAsync($q, $page = 1, $page_size = 20, $search_mode = 'fuzzy', $resource_type = null, $resource_id = null, $granularity = null, $language = null, $category = null, $tags = null, $page_refs = null, $related_id = null, $author = null, $channel = null, string $contentType = self::contentTypes['getApiV3Search'][0])
+    {
+        return $this->getApiV3SearchAsyncWithHttpInfo($q, $page, $page_size, $search_mode, $resource_type, $resource_id, $granularity, $language, $category, $tags, $page_refs, $related_id, $author, $channel, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3SearchAsyncWithHttpInfo
+     *
+     * 全文检索
+     *
+     * @param  string $q 搜索关键词 (required)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $page_size 每页数量,上限 100 (optional, default to 20)
+     * @param  string|null $search_mode 检索模式 (optional, default to 'fuzzy')
+     * @param  string|null $resource_type 资源类型 (optional)
+     * @param  string|null $resource_id 限定某个资源 id (optional)
+     * @param  string|null $granularity 文档颗粒度 (optional)
+     * @param  string|null $language 语言 (optional)
+     * @param  string|null $category 文档分类,逗号分隔 (optional)
+     * @param  string|null $tags 标签过滤,逗号分隔 (optional)
+     * @param  string[]|null $page_refs 页码标记 (optional)
+     * @param  string[]|null $related_id 关联 id (optional)
+     * @param  string|null $author 按作者或译者过滤 (optional)
+     * @param  string|null $channel 按来源 channel 过滤 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3SearchAsyncWithHttpInfo($q, $page = 1, $page_size = 20, $search_mode = 'fuzzy', $resource_type = null, $resource_id = null, $granularity = null, $language = null, $category = null, $tags = null, $page_refs = null, $related_id = null, $author = null, $channel = null, string $contentType = self::contentTypes['getApiV3Search'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3Search200Response';
+        $request = $this->getApiV3SearchRequest($q, $page, $page_size, $search_mode, $resource_type, $resource_id, $granularity, $language, $category, $tags, $page_refs, $related_id, $author, $channel, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3Search'
+     *
+     * @param  string $q 搜索关键词 (required)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $page_size 每页数量,上限 100 (optional, default to 20)
+     * @param  string|null $search_mode 检索模式 (optional, default to 'fuzzy')
+     * @param  string|null $resource_type 资源类型 (optional)
+     * @param  string|null $resource_id 限定某个资源 id (optional)
+     * @param  string|null $granularity 文档颗粒度 (optional)
+     * @param  string|null $language 语言 (optional)
+     * @param  string|null $category 文档分类,逗号分隔 (optional)
+     * @param  string|null $tags 标签过滤,逗号分隔 (optional)
+     * @param  string[]|null $page_refs 页码标记 (optional)
+     * @param  string[]|null $related_id 关联 id (optional)
+     * @param  string|null $author 按作者或译者过滤 (optional)
+     * @param  string|null $channel 按来源 channel 过滤 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3SearchRequest($q, $page = 1, $page_size = 20, $search_mode = 'fuzzy', $resource_type = null, $resource_id = null, $granularity = null, $language = null, $category = null, $tags = null, $page_refs = null, $related_id = null, $author = null, $channel = null, string $contentType = self::contentTypes['getApiV3Search'][0])
+    {
+
+        // verify the required parameter 'q' is set
+        if ($q === null || (is_array($q) && count($q) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $q when calling getApiV3Search'
+            );
+        }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+        $resourcePath = '/v3/search';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $q,
+            'q', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            true // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page_size,
+            'page_size', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $search_mode,
+            'search_mode', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $resource_type,
+            'resource_type', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $resource_id,
+            'resource_id', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $granularity,
+            'granularity', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $language,
+            'language', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $category,
+            'category', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $tags,
+            'tags', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page_refs,
+            'page_refs', // param base name
+            'array', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $related_id,
+            'related_id', // param base name
+            'array', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $author,
+            'author', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $channel,
+            'channel', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation getApiV3SearchSearch
+     *
+     * 按 id 取单条检索结果
+     *
+     * @param  string $search OpenSearch 文档 id (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSearch'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3SearchSearch200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3SearchSearch($search, string $contentType = self::contentTypes['getApiV3SearchSearch'][0])
+    {
+        list($response) = $this->getApiV3SearchSearchWithHttpInfo($search, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3SearchSearchWithHttpInfo
+     *
+     * 按 id 取单条检索结果
+     *
+     * @param  string $search OpenSearch 文档 id (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSearch'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3SearchSearch200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3SearchSearchWithHttpInfo($search, string $contentType = self::contentTypes['getApiV3SearchSearch'][0])
+    {
+        $request = $this->getApiV3SearchSearchRequest($search, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3SearchSearch200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3SearchSearch200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3SearchSearch200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3SearchSearchAsync
+     *
+     * 按 id 取单条检索结果
+     *
+     * @param  string $search OpenSearch 文档 id (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSearch'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3SearchSearchAsync($search, string $contentType = self::contentTypes['getApiV3SearchSearch'][0])
+    {
+        return $this->getApiV3SearchSearchAsyncWithHttpInfo($search, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3SearchSearchAsyncWithHttpInfo
+     *
+     * 按 id 取单条检索结果
+     *
+     * @param  string $search OpenSearch 文档 id (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSearch'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3SearchSearchAsyncWithHttpInfo($search, string $contentType = self::contentTypes['getApiV3SearchSearch'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3SearchSearch200Response';
+        $request = $this->getApiV3SearchSearchRequest($search, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3SearchSearch'
+     *
+     * @param  string $search OpenSearch 文档 id (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSearch'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3SearchSearchRequest($search, string $contentType = self::contentTypes['getApiV3SearchSearch'][0])
+    {
+
+        // verify the required parameter 'search' is set
+        if ($search === null || (is_array($search) && count($search) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $search when calling getApiV3SearchSearch'
+            );
+        }
+
+
+        $resourcePath = '/v3/search/{search}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+        // path params
+        if ($search !== null) {
+            $resourcePath = str_replace(
+                '{search}',
+                ObjectSerializer::toPathValue($search),
+                $resourcePath
+            );
+        }
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation postApiV3Search
+     *
+     * 全文检索(POST)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SearchRequest $post_api_v3_search_request post_api_v3_search_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3Search200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function postApiV3Search($post_api_v3_search_request, string $contentType = self::contentTypes['postApiV3Search'][0])
+    {
+        list($response) = $this->postApiV3SearchWithHttpInfo($post_api_v3_search_request, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation postApiV3SearchWithHttpInfo
+     *
+     * 全文检索(POST)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SearchRequest $post_api_v3_search_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3Search200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function postApiV3SearchWithHttpInfo($post_api_v3_search_request, string $contentType = self::contentTypes['postApiV3Search'][0])
+    {
+        $request = $this->postApiV3SearchRequest($post_api_v3_search_request, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3Search200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3Search200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3Search200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation postApiV3SearchAsync
+     *
+     * 全文检索(POST)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SearchRequest $post_api_v3_search_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3SearchAsync($post_api_v3_search_request, string $contentType = self::contentTypes['postApiV3Search'][0])
+    {
+        return $this->postApiV3SearchAsyncWithHttpInfo($post_api_v3_search_request, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation postApiV3SearchAsyncWithHttpInfo
+     *
+     * 全文检索(POST)
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SearchRequest $post_api_v3_search_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function postApiV3SearchAsyncWithHttpInfo($post_api_v3_search_request, string $contentType = self::contentTypes['postApiV3Search'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3Search200Response';
+        $request = $this->postApiV3SearchRequest($post_api_v3_search_request, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'postApiV3Search'
+     *
+     * @param  \WikipaliApi\Model\PostApiV3SearchRequest $post_api_v3_search_request (required)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['postApiV3Search'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function postApiV3SearchRequest($post_api_v3_search_request, string $contentType = self::contentTypes['postApiV3Search'][0])
+    {
+
+        // verify the required parameter 'post_api_v3_search_request' is set
+        if ($post_api_v3_search_request === null || (is_array($post_api_v3_search_request) && count($post_api_v3_search_request) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $post_api_v3_search_request when calling postApiV3Search'
+            );
+        }
+
+
+        $resourcePath = '/v3/search';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (isset($post_api_v3_search_request)) {
+            if (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the body
+                try {
+                    $httpBody = json_encode(ObjectSerializer::sanitizeForSerialization($post_api_v3_search_request), JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                $httpBody = $post_api_v3_search_request;
+            }
+        } elseif (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'POST',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 529 - 0
api-test/client/lib/Api/SearchSuggestApi.php

@@ -0,0 +1,529 @@
+<?php
+/**
+ * SearchSuggestApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * SearchSuggestApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class SearchSuggestApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3SearchSuggest' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3SearchSuggest
+     *
+     * 搜索建议(自动补全)
+     *
+     * @param  string $q 已输入的查询文本 (required)
+     * @param  string|null $fields 在哪些字段上补全,逗号分隔;不传则全查 (optional)
+     * @param  string|null $language 语言过滤 (optional)
+     * @param  int|null $limit 每个字段返回的建议数,上限 50 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSuggest'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3Search200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3SearchSuggest($q, $fields = null, $language = null, $limit = 10, string $contentType = self::contentTypes['getApiV3SearchSuggest'][0])
+    {
+        list($response) = $this->getApiV3SearchSuggestWithHttpInfo($q, $fields, $language, $limit, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3SearchSuggestWithHttpInfo
+     *
+     * 搜索建议(自动补全)
+     *
+     * @param  string $q 已输入的查询文本 (required)
+     * @param  string|null $fields 在哪些字段上补全,逗号分隔;不传则全查 (optional)
+     * @param  string|null $language 语言过滤 (optional)
+     * @param  int|null $limit 每个字段返回的建议数,上限 50 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSuggest'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3Search200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3SearchSuggestWithHttpInfo($q, $fields = null, $language = null, $limit = 10, string $contentType = self::contentTypes['getApiV3SearchSuggest'][0])
+    {
+        $request = $this->getApiV3SearchSuggestRequest($q, $fields, $language, $limit, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3Search200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3Search200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3Search200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3SearchSuggestAsync
+     *
+     * 搜索建议(自动补全)
+     *
+     * @param  string $q 已输入的查询文本 (required)
+     * @param  string|null $fields 在哪些字段上补全,逗号分隔;不传则全查 (optional)
+     * @param  string|null $language 语言过滤 (optional)
+     * @param  int|null $limit 每个字段返回的建议数,上限 50 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSuggest'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3SearchSuggestAsync($q, $fields = null, $language = null, $limit = 10, string $contentType = self::contentTypes['getApiV3SearchSuggest'][0])
+    {
+        return $this->getApiV3SearchSuggestAsyncWithHttpInfo($q, $fields, $language, $limit, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3SearchSuggestAsyncWithHttpInfo
+     *
+     * 搜索建议(自动补全)
+     *
+     * @param  string $q 已输入的查询文本 (required)
+     * @param  string|null $fields 在哪些字段上补全,逗号分隔;不传则全查 (optional)
+     * @param  string|null $language 语言过滤 (optional)
+     * @param  int|null $limit 每个字段返回的建议数,上限 50 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSuggest'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3SearchSuggestAsyncWithHttpInfo($q, $fields = null, $language = null, $limit = 10, string $contentType = self::contentTypes['getApiV3SearchSuggest'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3Search200Response';
+        $request = $this->getApiV3SearchSuggestRequest($q, $fields, $language, $limit, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3SearchSuggest'
+     *
+     * @param  string $q 已输入的查询文本 (required)
+     * @param  string|null $fields 在哪些字段上补全,逗号分隔;不传则全查 (optional)
+     * @param  string|null $language 语言过滤 (optional)
+     * @param  int|null $limit 每个字段返回的建议数,上限 50 (optional, default to 10)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3SearchSuggest'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3SearchSuggestRequest($q, $fields = null, $language = null, $limit = 10, string $contentType = self::contentTypes['getApiV3SearchSuggest'][0])
+    {
+
+        // verify the required parameter 'q' is set
+        if ($q === null || (is_array($q) && count($q) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $q when calling getApiV3SearchSuggest'
+            );
+        }
+
+
+
+
+
+        $resourcePath = '/v3/search-suggest';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $q,
+            'q', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            true // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $fields,
+            'fields', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $language,
+            'language', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $limit,
+            'limit', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 632 - 0
api-test/client/lib/Api/TipitakaReadingApi.php

@@ -0,0 +1,632 @@
+<?php
+/**
+ * TipitakaReadingApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * TipitakaReadingApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class TipitakaReadingApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3TipitakaReadingChannel' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3TipitakaReadingChannel
+     *
+     * 读取译文
+     *
+     * @param  string $channel 译文 channel 的 uuid (required)
+     * @param  int|null $book 典籍 id。与 chapter / para / to 一起用时必填 (optional)
+     * @param  int|null $chapter 章节起始段落号,服务端按 chapter_len 展开成段落区间。 与 para / to 互斥 (optional)
+     * @param  int|null $para 起始段落号 (optional)
+     * @param  int|null $to 结束段落号(含)。不传则等于 para,即只取一段 (optional)
+     * @param  string|null $after 游标,取 meta.next_cursor 原样回传 (optional)
+     * @param  int|null $page_size 每块大小,含义由 unit 决定。上限 unit&#x3D;para 时 200、 unit&#x3D;byte 时 5000,超出不报错按上限算,meta.page_size 回实际生效的值 (optional, default to 10)
+     * @param  string|null $unit 每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段 (optional, default to 'para')
+     * @param  string|null $format 内容格式 (optional, default to 'html')
+     * @param  string|null $include 输出口径:display 整段合并,sentences 逐句,all 两者都给。 不叫 view 是因为 v2 的 &#x60;view&#x3D;&#x60; 是必填互斥的业务路径开关,名字撞上会误导 (optional, default to 'display')
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaReadingChannel'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3TipitakaReadingChannel200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3TipitakaReadingChannel($channel, $book = null, $chapter = null, $para = null, $to = null, $after = null, $page_size = 10, $unit = 'para', $format = 'html', $include = 'display', string $contentType = self::contentTypes['getApiV3TipitakaReadingChannel'][0])
+    {
+        list($response) = $this->getApiV3TipitakaReadingChannelWithHttpInfo($channel, $book, $chapter, $para, $to, $after, $page_size, $unit, $format, $include, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3TipitakaReadingChannelWithHttpInfo
+     *
+     * 读取译文
+     *
+     * @param  string $channel 译文 channel 的 uuid (required)
+     * @param  int|null $book 典籍 id。与 chapter / para / to 一起用时必填 (optional)
+     * @param  int|null $chapter 章节起始段落号,服务端按 chapter_len 展开成段落区间。 与 para / to 互斥 (optional)
+     * @param  int|null $para 起始段落号 (optional)
+     * @param  int|null $to 结束段落号(含)。不传则等于 para,即只取一段 (optional)
+     * @param  string|null $after 游标,取 meta.next_cursor 原样回传 (optional)
+     * @param  int|null $page_size 每块大小,含义由 unit 决定。上限 unit&#x3D;para 时 200、 unit&#x3D;byte 时 5000,超出不报错按上限算,meta.page_size 回实际生效的值 (optional, default to 10)
+     * @param  string|null $unit 每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段 (optional, default to 'para')
+     * @param  string|null $format 内容格式 (optional, default to 'html')
+     * @param  string|null $include 输出口径:display 整段合并,sentences 逐句,all 两者都给。 不叫 view 是因为 v2 的 &#x60;view&#x3D;&#x60; 是必填互斥的业务路径开关,名字撞上会误导 (optional, default to 'display')
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaReadingChannel'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3TipitakaReadingChannel200Response|\WikipaliApi\Model\ProblemDetails|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3TipitakaReadingChannelWithHttpInfo($channel, $book = null, $chapter = null, $para = null, $to = null, $after = null, $page_size = 10, $unit = 'para', $format = 'html', $include = 'display', string $contentType = self::contentTypes['getApiV3TipitakaReadingChannel'][0])
+    {
+        $request = $this->getApiV3TipitakaReadingChannelRequest($channel, $book, $chapter, $para, $to, $after, $page_size, $unit, $format, $include, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3TipitakaReadingChannel200Response',
+                        $request,
+                        $response,
+                    );
+                case 404:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3TipitakaReadingChannel200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3TipitakaReadingChannel200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 404:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3TipitakaReadingChannelAsync
+     *
+     * 读取译文
+     *
+     * @param  string $channel 译文 channel 的 uuid (required)
+     * @param  int|null $book 典籍 id。与 chapter / para / to 一起用时必填 (optional)
+     * @param  int|null $chapter 章节起始段落号,服务端按 chapter_len 展开成段落区间。 与 para / to 互斥 (optional)
+     * @param  int|null $para 起始段落号 (optional)
+     * @param  int|null $to 结束段落号(含)。不传则等于 para,即只取一段 (optional)
+     * @param  string|null $after 游标,取 meta.next_cursor 原样回传 (optional)
+     * @param  int|null $page_size 每块大小,含义由 unit 决定。上限 unit&#x3D;para 时 200、 unit&#x3D;byte 时 5000,超出不报错按上限算,meta.page_size 回实际生效的值 (optional, default to 10)
+     * @param  string|null $unit 每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段 (optional, default to 'para')
+     * @param  string|null $format 内容格式 (optional, default to 'html')
+     * @param  string|null $include 输出口径:display 整段合并,sentences 逐句,all 两者都给。 不叫 view 是因为 v2 的 &#x60;view&#x3D;&#x60; 是必填互斥的业务路径开关,名字撞上会误导 (optional, default to 'display')
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaReadingChannel'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaReadingChannelAsync($channel, $book = null, $chapter = null, $para = null, $to = null, $after = null, $page_size = 10, $unit = 'para', $format = 'html', $include = 'display', string $contentType = self::contentTypes['getApiV3TipitakaReadingChannel'][0])
+    {
+        return $this->getApiV3TipitakaReadingChannelAsyncWithHttpInfo($channel, $book, $chapter, $para, $to, $after, $page_size, $unit, $format, $include, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3TipitakaReadingChannelAsyncWithHttpInfo
+     *
+     * 读取译文
+     *
+     * @param  string $channel 译文 channel 的 uuid (required)
+     * @param  int|null $book 典籍 id。与 chapter / para / to 一起用时必填 (optional)
+     * @param  int|null $chapter 章节起始段落号,服务端按 chapter_len 展开成段落区间。 与 para / to 互斥 (optional)
+     * @param  int|null $para 起始段落号 (optional)
+     * @param  int|null $to 结束段落号(含)。不传则等于 para,即只取一段 (optional)
+     * @param  string|null $after 游标,取 meta.next_cursor 原样回传 (optional)
+     * @param  int|null $page_size 每块大小,含义由 unit 决定。上限 unit&#x3D;para 时 200、 unit&#x3D;byte 时 5000,超出不报错按上限算,meta.page_size 回实际生效的值 (optional, default to 10)
+     * @param  string|null $unit 每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段 (optional, default to 'para')
+     * @param  string|null $format 内容格式 (optional, default to 'html')
+     * @param  string|null $include 输出口径:display 整段合并,sentences 逐句,all 两者都给。 不叫 view 是因为 v2 的 &#x60;view&#x3D;&#x60; 是必填互斥的业务路径开关,名字撞上会误导 (optional, default to 'display')
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaReadingChannel'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaReadingChannelAsyncWithHttpInfo($channel, $book = null, $chapter = null, $para = null, $to = null, $after = null, $page_size = 10, $unit = 'para', $format = 'html', $include = 'display', string $contentType = self::contentTypes['getApiV3TipitakaReadingChannel'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3TipitakaReadingChannel200Response';
+        $request = $this->getApiV3TipitakaReadingChannelRequest($channel, $book, $chapter, $para, $to, $after, $page_size, $unit, $format, $include, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3TipitakaReadingChannel'
+     *
+     * @param  string $channel 译文 channel 的 uuid (required)
+     * @param  int|null $book 典籍 id。与 chapter / para / to 一起用时必填 (optional)
+     * @param  int|null $chapter 章节起始段落号,服务端按 chapter_len 展开成段落区间。 与 para / to 互斥 (optional)
+     * @param  int|null $para 起始段落号 (optional)
+     * @param  int|null $to 结束段落号(含)。不传则等于 para,即只取一段 (optional)
+     * @param  string|null $after 游标,取 meta.next_cursor 原样回传 (optional)
+     * @param  int|null $page_size 每块大小,含义由 unit 决定。上限 unit&#x3D;para 时 200、 unit&#x3D;byte 时 5000,超出不报错按上限算,meta.page_size 回实际生效的值 (optional, default to 10)
+     * @param  string|null $unit 每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段 (optional, default to 'para')
+     * @param  string|null $format 内容格式 (optional, default to 'html')
+     * @param  string|null $include 输出口径:display 整段合并,sentences 逐句,all 两者都给。 不叫 view 是因为 v2 的 &#x60;view&#x3D;&#x60; 是必填互斥的业务路径开关,名字撞上会误导 (optional, default to 'display')
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaReadingChannel'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3TipitakaReadingChannelRequest($channel, $book = null, $chapter = null, $para = null, $to = null, $after = null, $page_size = 10, $unit = 'para', $format = 'html', $include = 'display', string $contentType = self::contentTypes['getApiV3TipitakaReadingChannel'][0])
+    {
+
+        // verify the required parameter 'channel' is set
+        if ($channel === null || (is_array($channel) && count($channel) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $channel when calling getApiV3TipitakaReadingChannel'
+            );
+        }
+
+
+
+
+
+
+
+
+
+
+
+        $resourcePath = '/v3/tipitaka-reading/{channel}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book,
+            'book', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $chapter,
+            'chapter', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $para,
+            'para', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $to,
+            'to', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $after,
+            'after', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page_size,
+            'page_size', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $unit,
+            'unit', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $format,
+            'format', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $include,
+            'include', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+        // path params
+        if ($channel !== null) {
+            $resourcePath = str_replace(
+                '{channel}',
+                ObjectSerializer::toPathValue($channel),
+                $resourcePath
+            );
+        }
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 1216 - 0
api-test/client/lib/Api/TipitakaRelatedParagraphsApi.php

@@ -0,0 +1,1216 @@
+<?php
+/**
+ * TipitakaRelatedParagraphsApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * TipitakaRelatedParagraphsApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class TipitakaRelatedParagraphsApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3TipitakaRelatedParagraphs' => [
+            'application/json',
+        ],
+        'getApiV3TipitakaRelatedParagraphsAggregate' => [
+            'application/json',
+        ],
+        'getApiV3TipitakaRelatedParagraphsAggregateBookName' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphs
+     *
+     * 列出一个段落的关联段落
+     *
+     * @param  int|null $book 典籍文件号(1-217)。与 para 一起用,prohibits 下面那组 (optional)
+     * @param  int|null $para 段落号。与 book 一起用 (optional)
+     * @param  string|null $book_name SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组 (optional)
+     * @param  int|null $cs_para 书内段落号。与 book_name 一起用 (optional)
+     * @param  int|null $book_id 真书号(book_titles.sn),可选收窄 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphs'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3TipitakaRelatedParagraphs($book = null, $para = null, $book_name = null, $cs_para = null, $book_id = null, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphs'][0])
+    {
+        list($response) = $this->getApiV3TipitakaRelatedParagraphsWithHttpInfo($book, $para, $book_name, $cs_para, $book_id, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsWithHttpInfo
+     *
+     * 列出一个段落的关联段落
+     *
+     * @param  int|null $book 典籍文件号(1-217)。与 para 一起用,prohibits 下面那组 (optional)
+     * @param  int|null $para 段落号。与 book 一起用 (optional)
+     * @param  string|null $book_name SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组 (optional)
+     * @param  int|null $cs_para 书内段落号。与 book_name 一起用 (optional)
+     * @param  int|null $book_id 真书号(book_titles.sn),可选收窄 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphs'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3TipitakaRelatedParagraphsWithHttpInfo($book = null, $para = null, $book_name = null, $cs_para = null, $book_id = null, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphs'][0])
+    {
+        $request = $this->getApiV3TipitakaRelatedParagraphsRequest($book, $para, $book_name, $cs_para, $book_id, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAsync
+     *
+     * 列出一个段落的关联段落
+     *
+     * @param  int|null $book 典籍文件号(1-217)。与 para 一起用,prohibits 下面那组 (optional)
+     * @param  int|null $para 段落号。与 book 一起用 (optional)
+     * @param  string|null $book_name SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组 (optional)
+     * @param  int|null $cs_para 书内段落号。与 book_name 一起用 (optional)
+     * @param  int|null $book_id 真书号(book_titles.sn),可选收窄 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphs'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaRelatedParagraphsAsync($book = null, $para = null, $book_name = null, $cs_para = null, $book_id = null, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphs'][0])
+    {
+        return $this->getApiV3TipitakaRelatedParagraphsAsyncWithHttpInfo($book, $para, $book_name, $cs_para, $book_id, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAsyncWithHttpInfo
+     *
+     * 列出一个段落的关联段落
+     *
+     * @param  int|null $book 典籍文件号(1-217)。与 para 一起用,prohibits 下面那组 (optional)
+     * @param  int|null $para 段落号。与 book 一起用 (optional)
+     * @param  string|null $book_name SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组 (optional)
+     * @param  int|null $cs_para 书内段落号。与 book_name 一起用 (optional)
+     * @param  int|null $book_id 真书号(book_titles.sn),可选收窄 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphs'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaRelatedParagraphsAsyncWithHttpInfo($book = null, $para = null, $book_name = null, $cs_para = null, $book_id = null, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphs'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200Response';
+        $request = $this->getApiV3TipitakaRelatedParagraphsRequest($book, $para, $book_name, $cs_para, $book_id, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3TipitakaRelatedParagraphs'
+     *
+     * @param  int|null $book 典籍文件号(1-217)。与 para 一起用,prohibits 下面那组 (optional)
+     * @param  int|null $para 段落号。与 book 一起用 (optional)
+     * @param  string|null $book_name SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组 (optional)
+     * @param  int|null $cs_para 书内段落号。与 book_name 一起用 (optional)
+     * @param  int|null $book_id 真书号(book_titles.sn),可选收窄 (optional)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphs'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3TipitakaRelatedParagraphsRequest($book = null, $para = null, $book_name = null, $cs_para = null, $book_id = null, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphs'][0])
+    {
+
+
+
+
+
+
+
+        $resourcePath = '/v3/tipitaka-related-paragraphs';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book,
+            'book', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $para,
+            'para', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book_name,
+            'book_name', // param base name
+            'string', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $cs_para,
+            'cs_para', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book_id,
+            'book_id', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregate
+     *
+     * 列出所有 SC 缩写
+     *
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregate($file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'][0])
+    {
+        list($response) = $this->getApiV3TipitakaRelatedParagraphsAggregateWithHttpInfo($file, $book, $page, $per_page, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateWithHttpInfo
+     *
+     * 列出所有 SC 缩写
+     *
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateWithHttpInfo($file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'][0])
+    {
+        $request = $this->getApiV3TipitakaRelatedParagraphsAggregateRequest($file, $book, $page, $per_page, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateAsync
+     *
+     * 列出所有 SC 缩写
+     *
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateAsync($file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'][0])
+    {
+        return $this->getApiV3TipitakaRelatedParagraphsAggregateAsyncWithHttpInfo($file, $book, $page, $per_page, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateAsyncWithHttpInfo
+     *
+     * 列出所有 SC 缩写
+     *
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateAsyncWithHttpInfo($file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200Response';
+        $request = $this->getApiV3TipitakaRelatedParagraphsAggregateRequest($file, $book, $page, $per_page, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3TipitakaRelatedParagraphsAggregate'
+     *
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateRequest($file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregate'][0])
+    {
+
+
+
+
+
+
+        $resourcePath = '/v3/tipitaka-related-paragraphs/aggregate';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $file,
+            'file', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book,
+            'book', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $per_page,
+            'per_page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateBookName
+     *
+     * 列出某个 SC 缩写下的 cs_para
+     *
+     * @param  string $book_name SC 风格书名缩写(如 dn1、an2) (required)
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateBookName($book_name, $file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'][0])
+    {
+        list($response) = $this->getApiV3TipitakaRelatedParagraphsAggregateBookNameWithHttpInfo($book_name, $file, $book, $page, $per_page, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateBookNameWithHttpInfo
+     *
+     * 列出某个 SC 缩写下的 cs_para
+     *
+     * @param  string $book_name SC 风格书名缩写(如 dn1、an2) (required)
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateBookNameWithHttpInfo($book_name, $file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'][0])
+    {
+        $request = $this->getApiV3TipitakaRelatedParagraphsAggregateBookNameRequest($book_name, $file, $book, $page, $per_page, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateBookNameAsync
+     *
+     * 列出某个 SC 缩写下的 cs_para
+     *
+     * @param  string $book_name SC 风格书名缩写(如 dn1、an2) (required)
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateBookNameAsync($book_name, $file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'][0])
+    {
+        return $this->getApiV3TipitakaRelatedParagraphsAggregateBookNameAsyncWithHttpInfo($book_name, $file, $book, $page, $per_page, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3TipitakaRelatedParagraphsAggregateBookNameAsyncWithHttpInfo
+     *
+     * 列出某个 SC 缩写下的 cs_para
+     *
+     * @param  string $book_name SC 风格书名缩写(如 dn1、an2) (required)
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateBookNameAsyncWithHttpInfo($book_name, $file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response';
+        $request = $this->getApiV3TipitakaRelatedParagraphsAggregateBookNameRequest($book_name, $file, $book, $page, $per_page, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3TipitakaRelatedParagraphsAggregateBookName'
+     *
+     * @param  string $book_name SC 风格书名缩写(如 dn1、an2) (required)
+     * @param  int|null $file 典籍文件号过滤(对应表里的 book 列) (optional)
+     * @param  int|null $book 真书号过滤(对应表里的 book_id 列) (optional)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 15)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3TipitakaRelatedParagraphsAggregateBookNameRequest($book_name, $file = null, $book = null, $page = 1, $per_page = 15, string $contentType = self::contentTypes['getApiV3TipitakaRelatedParagraphsAggregateBookName'][0])
+    {
+
+        // verify the required parameter 'book_name' is set
+        if ($book_name === null || (is_array($book_name) && count($book_name) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $book_name when calling getApiV3TipitakaRelatedParagraphsAggregateBookName'
+            );
+        }
+
+
+
+
+
+
+        $resourcePath = '/v3/tipitaka-related-paragraphs/aggregate/{book_name}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $file,
+            'file', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $book,
+            'book', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $per_page,
+            'per_page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+        // path params
+        if ($book_name !== null) {
+            $resourcePath = str_replace(
+                '{book_name}',
+                ObjectSerializer::toPathValue($book_name),
+                $resourcePath
+            );
+        }
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 449 - 0
api-test/client/lib/Api/UpgradeApi.php

@@ -0,0 +1,449 @@
+<?php
+/**
+ * UpgradeApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * UpgradeApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class UpgradeApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3Upgrade' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3Upgrade
+     *
+     * 客户端升级检查
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Upgrade'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3SearchSearch200Response
+     */
+    public function getApiV3Upgrade(string $contentType = self::contentTypes['getApiV3Upgrade'][0])
+    {
+        list($response) = $this->getApiV3UpgradeWithHttpInfo($contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3UpgradeWithHttpInfo
+     *
+     * 客户端升级检查
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Upgrade'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3SearchSearch200Response, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3UpgradeWithHttpInfo(string $contentType = self::contentTypes['getApiV3Upgrade'][0])
+    {
+        $request = $this->getApiV3UpgradeRequest($contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3SearchSearch200Response',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3SearchSearch200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3SearchSearch200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3UpgradeAsync
+     *
+     * 客户端升级检查
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Upgrade'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3UpgradeAsync(string $contentType = self::contentTypes['getApiV3Upgrade'][0])
+    {
+        return $this->getApiV3UpgradeAsyncWithHttpInfo($contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3UpgradeAsyncWithHttpInfo
+     *
+     * 客户端升级检查
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Upgrade'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3UpgradeAsyncWithHttpInfo(string $contentType = self::contentTypes['getApiV3Upgrade'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3SearchSearch200Response';
+        $request = $this->getApiV3UpgradeRequest($contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3Upgrade'
+     *
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3Upgrade'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3UpgradeRequest(string $contentType = self::contentTypes['getApiV3Upgrade'][0])
+    {
+
+
+        $resourcePath = '/v3/upgrade';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+
+
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 119 - 0
api-test/client/lib/ApiException.php

@@ -0,0 +1,119 @@
+<?php
+/**
+ * ApiException
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi;
+
+use \Exception;
+
+/**
+ * ApiException Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class ApiException extends Exception
+{
+    /**
+     * The HTTP body of the server response either as Json or string.
+     *
+     * @var \stdClass|string|null
+     */
+    protected $responseBody;
+
+    /**
+     * The HTTP header of the server response.
+     *
+     * @var string[][]|null
+     */
+    protected $responseHeaders;
+
+    /**
+     * The deserialized response object
+     *
+     * @var \stdClass|string|null
+     */
+    protected $responseObject;
+
+    /**
+     * Constructor
+     *
+     * @param string                $message         Error message
+     * @param int                   $code            HTTP status code
+     * @param string[][]|null       $responseHeaders HTTP response header
+     * @param \stdClass|string|null $responseBody    HTTP decoded body of the server response either as \stdClass or string
+     */
+    public function __construct($message = "", $code = 0, $responseHeaders = [], $responseBody = null)
+    {
+        parent::__construct($message, $code);
+        $this->responseHeaders = $responseHeaders;
+        $this->responseBody = $responseBody;
+    }
+
+    /**
+     * Gets the HTTP response header
+     *
+     * @return string[][]|null HTTP response header
+     */
+    public function getResponseHeaders()
+    {
+        return $this->responseHeaders;
+    }
+
+    /**
+     * Gets the HTTP body of the server response either as Json or string
+     *
+     * @return \stdClass|string|null HTTP body of the server response either as \stdClass or string
+     */
+    public function getResponseBody()
+    {
+        return $this->responseBody;
+    }
+
+    /**
+     * Sets the deserialized response object (during deserialization)
+     *
+     * @param mixed $obj Deserialized response object
+     *
+     * @return void
+     */
+    public function setResponseObject($obj)
+    {
+        $this->responseObject = $obj;
+    }
+
+    /**
+     * Gets the deserialized response object (during deserialization)
+     *
+     * @return mixed the deserialized response object
+     */
+    public function getResponseObject()
+    {
+        return $this->responseObject;
+    }
+}

+ 613 - 0
api-test/client/lib/Configuration.php

@@ -0,0 +1,613 @@
+<?php
+/**
+ * Configuration
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi;
+
+/**
+ * Configuration Class Doc Comment
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class Configuration
+{
+    public const BOOLEAN_FORMAT_INT = 'int';
+    public const BOOLEAN_FORMAT_STRING = 'string';
+
+    /**
+     * @var Configuration
+     */
+    private static $defaultConfiguration;
+
+    /**
+     * Associate array to store API key(s)
+     *
+     * @var string[]
+     */
+    protected $apiKeys = [];
+
+    /**
+     * Associate array to store API prefix (e.g. Bearer)
+     *
+     * @var string[]
+     */
+    protected $apiKeyPrefixes = [];
+
+    /**
+     * Access token for OAuth/Bearer authentication
+     *
+     * @var string
+     */
+    protected $accessToken = '';
+
+    /**
+     * Boolean format for query string
+     *
+     * @var string
+     */
+    protected $booleanFormatForQueryString = self::BOOLEAN_FORMAT_INT;
+
+    /**
+     * Username for HTTP basic authentication
+     *
+     * @var string
+     */
+    protected $username = '';
+
+    /**
+     * Password for HTTP basic authentication
+     *
+     * @var string
+     */
+    protected $password = '';
+
+    /**
+     * The host
+     *
+     * @var string
+     */
+    protected $host = '/api';
+
+    /**
+     * User agent of the HTTP request, set to "OpenAPI-Generator/{version}/PHP" by default
+     *
+     * @var string
+     */
+    protected $userAgent = 'OpenAPI-Generator/1.0.0/PHP';
+
+    /**
+     * Debug switch (default set to false)
+     *
+     * @var bool
+     */
+    protected $debug = false;
+
+    /**
+     * Debug file location (log to STDOUT by default)
+     *
+     * @var string
+     */
+    protected $debugFile = 'php://output';
+
+    /**
+     * Debug file location (log to STDOUT by default)
+     *
+     * @var string
+     */
+    protected $tempFolderPath;
+
+    /**
+     * Path to a certificate file, for mTLS
+     *
+     * @var string
+     */
+    protected $certFile;
+
+    /**
+     * Path to a key file, for mTLS
+     *
+     * @var string
+     */
+    protected $keyFile;
+
+    /**
+     * Constructor
+     */
+    public function __construct()
+    {
+        $this->tempFolderPath = sys_get_temp_dir();
+    }
+
+    /**
+     * Sets API key
+     *
+     * @param string $apiKeyIdentifier API key identifier (authentication scheme)
+     * @param string $key              API key or token
+     *
+     * @return $this
+     */
+    public function setApiKey($apiKeyIdentifier, $key)
+    {
+        $this->apiKeys[$apiKeyIdentifier] = $key;
+        return $this;
+    }
+
+    /**
+     * Gets API key
+     *
+     * @param string $apiKeyIdentifier API key identifier (authentication scheme)
+     *
+     * @return null|string API key or token
+     */
+    public function getApiKey($apiKeyIdentifier)
+    {
+        return isset($this->apiKeys[$apiKeyIdentifier]) ? $this->apiKeys[$apiKeyIdentifier] : null;
+    }
+
+    /**
+     * Sets the prefix for API key (e.g. Bearer)
+     *
+     * @param string $apiKeyIdentifier API key identifier (authentication scheme)
+     * @param string $prefix           API key prefix, e.g. Bearer
+     *
+     * @return $this
+     */
+    public function setApiKeyPrefix($apiKeyIdentifier, $prefix)
+    {
+        $this->apiKeyPrefixes[$apiKeyIdentifier] = $prefix;
+        return $this;
+    }
+
+    /**
+     * Gets API key prefix
+     *
+     * @param string $apiKeyIdentifier API key identifier (authentication scheme)
+     *
+     * @return null|string
+     */
+    public function getApiKeyPrefix($apiKeyIdentifier)
+    {
+        return isset($this->apiKeyPrefixes[$apiKeyIdentifier]) ? $this->apiKeyPrefixes[$apiKeyIdentifier] : null;
+    }
+
+    /**
+     * Sets the access token for OAuth
+     *
+     * @param string $accessToken Token for OAuth
+     *
+     * @return $this
+     */
+    public function setAccessToken($accessToken)
+    {
+        $this->accessToken = $accessToken;
+        return $this;
+    }
+
+    /**
+     * Gets the access token for OAuth
+     *
+     * @return string Access token for OAuth
+     */
+    public function getAccessToken()
+    {
+        return $this->accessToken;
+    }
+
+    /**
+     * Sets boolean format for query string.
+     *
+     * @param string $booleanFormat Boolean format for query string
+     *
+     * @return $this
+     */
+    public function setBooleanFormatForQueryString(string $booleanFormat)
+    {
+        $this->booleanFormatForQueryString = $booleanFormat;
+
+        return $this;
+    }
+
+    /**
+     * Gets boolean format for query string.
+     *
+     * @return string Boolean format for query string
+     */
+    public function getBooleanFormatForQueryString(): string
+    {
+        return $this->booleanFormatForQueryString;
+    }
+
+    /**
+     * Sets the username for HTTP basic authentication
+     *
+     * @param string $username Username for HTTP basic authentication
+     *
+     * @return $this
+     */
+    public function setUsername($username)
+    {
+        $this->username = $username;
+        return $this;
+    }
+
+    /**
+     * Gets the username for HTTP basic authentication
+     *
+     * @return string Username for HTTP basic authentication
+     */
+    public function getUsername()
+    {
+        return $this->username;
+    }
+
+    /**
+     * Sets the password for HTTP basic authentication
+     *
+     * @param string $password Password for HTTP basic authentication
+     *
+     * @return $this
+     */
+    public function setPassword($password)
+    {
+        $this->password = $password;
+        return $this;
+    }
+
+    /**
+     * Gets the password for HTTP basic authentication
+     *
+     * @return string Password for HTTP basic authentication
+     */
+    public function getPassword()
+    {
+        return $this->password;
+    }
+
+    /**
+     * Sets the host
+     *
+     * @param string $host Host
+     *
+     * @return $this
+     */
+    public function setHost($host)
+    {
+        $this->host = $host;
+        return $this;
+    }
+
+    /**
+     * Gets the host
+     *
+     * @return string Host
+     */
+    public function getHost()
+    {
+        return $this->host;
+    }
+
+    /**
+     * Sets the user agent of the api client
+     *
+     * @param string $userAgent the user agent of the api client
+     *
+     * @throws \InvalidArgumentException
+     * @return $this
+     */
+    public function setUserAgent($userAgent)
+    {
+        if (!is_string($userAgent)) {
+            throw new \InvalidArgumentException('User-agent must be a string.');
+        }
+
+        $this->userAgent = $userAgent;
+        return $this;
+    }
+
+    /**
+     * Gets the user agent of the api client
+     *
+     * @return string user agent
+     */
+    public function getUserAgent()
+    {
+        return $this->userAgent;
+    }
+
+    /**
+     * Sets debug flag
+     *
+     * @param bool $debug Debug flag
+     *
+     * @return $this
+     */
+    public function setDebug($debug)
+    {
+        $this->debug = $debug;
+        return $this;
+    }
+
+    /**
+     * Gets the debug flag
+     *
+     * @return bool
+     */
+    public function getDebug()
+    {
+        return $this->debug;
+    }
+
+    /**
+     * Sets the debug file
+     *
+     * @param string $debugFile Debug file
+     *
+     * @return $this
+     */
+    public function setDebugFile($debugFile)
+    {
+        $this->debugFile = $debugFile;
+        return $this;
+    }
+
+    /**
+     * Gets the debug file
+     *
+     * @return string
+     */
+    public function getDebugFile()
+    {
+        return $this->debugFile;
+    }
+
+    /**
+     * Sets the temp folder path
+     *
+     * @param string $tempFolderPath Temp folder path
+     *
+     * @return $this
+     */
+    public function setTempFolderPath($tempFolderPath)
+    {
+        $this->tempFolderPath = $tempFolderPath;
+        return $this;
+    }
+
+    /**
+     * Gets the temp folder path
+     *
+     * @return string Temp folder path
+     */
+    public function getTempFolderPath()
+    {
+        return $this->tempFolderPath;
+    }
+
+    /**
+     * Sets the certificate file path, for mTLS
+     *
+     * @return $this
+     */
+     public function setCertFile($certFile)
+     {
+        $this->certFile = $certFile;
+        return $this;
+     }
+
+    /**
+     * Gets the certificate file path, for mTLS
+     *
+     * @return string Certificate file path
+     */
+    public function getCertFile()
+    {
+        return $this->certFile;
+    }
+
+    /**
+     * Sets the certificate key path, for mTLS
+     *
+     * @return $this
+     */
+     public function setKeyFile($keyFile)
+     {
+        $this->keyFile = $keyFile;
+        return $this;
+     }
+
+    /**
+     * Gets the certificate key path, for mTLS
+     *
+     * @return string Certificate key path
+     */
+    public function getKeyFile()
+    {
+        return $this->keyFile;
+    }
+    
+
+    /**
+     * Gets the default configuration instance
+     *
+     * @return Configuration
+     */
+    public static function getDefaultConfiguration()
+    {
+        if (self::$defaultConfiguration === null) {
+            self::$defaultConfiguration = new Configuration();
+        }
+
+        return self::$defaultConfiguration;
+    }
+
+    /**
+     * Sets the default configuration instance
+     *
+     * @param Configuration $config An instance of the Configuration Object
+     *
+     * @return void
+     */
+    public static function setDefaultConfiguration(Configuration $config)
+    {
+        self::$defaultConfiguration = $config;
+    }
+
+    /**
+     * Gets the essential information for debugging
+     *
+     * @return string The report for debugging
+     */
+    public static function toDebugReport()
+    {
+        $report  = 'PHP SDK (WikipaliApi) Debug Report:' . PHP_EOL;
+        $report .= '    OS: ' . php_uname() . PHP_EOL;
+        $report .= '    PHP Version: ' . PHP_VERSION . PHP_EOL;
+        $report .= '    The version of the OpenAPI document: 2026.9.28' . PHP_EOL;
+        $report .= '    SDK Package Version: 1.0.0' . PHP_EOL;
+        $report .= '    Temp Folder Path: ' . self::getDefaultConfiguration()->getTempFolderPath() . PHP_EOL;
+
+        return $report;
+    }
+
+    /**
+     * Get API key (with prefix if set)
+     *
+     * @param  string $apiKeyIdentifier name of apikey
+     *
+     * @return null|string API key with the prefix
+     */
+    public function getApiKeyWithPrefix($apiKeyIdentifier)
+    {
+        $prefix = $this->getApiKeyPrefix($apiKeyIdentifier);
+        $apiKey = $this->getApiKey($apiKeyIdentifier);
+
+        if ($apiKey === null) {
+            return null;
+        }
+
+        if ($prefix === null) {
+            $keyWithPrefix = $apiKey;
+        } else {
+            $keyWithPrefix = $prefix . ' ' . $apiKey;
+        }
+
+        return $keyWithPrefix;
+    }
+
+    /**
+     * Returns an array of host settings
+     *
+     * @return array an array of host settings
+     */
+    public function getHostSettings()
+    {
+        return [
+            [
+                "url" => "/api",
+                "description" => "同源(预览页里 Try it out 走 vite 代理,避免跨域)",
+            ],
+            [
+                "url" => "http://127.0.0.1:8000/api",
+                "description" => "本地开发(直连,浏览器里会被 CORS 挡住)",
+            ],
+            [
+                "url" => "https://staging.wikipali.org/api",
+                "description" => "测试",
+            ],
+            [
+                "url" => "https://www.wikipali.org/api",
+                "description" => "生产",
+            ],
+            [
+                "url" => "https://next.wikipali.org/api",
+                "description" => "生产预览",
+            ],
+            [
+                "url" => "https://www.wikipali.cc/api",
+                "description" => "生产(中国)",
+            ],
+            [
+                "url" => "https://next.wikipali.cc/api",
+                "description" => "生产预览(中国)",
+            ]
+        ];
+    }
+
+    /**
+    * Returns URL based on host settings, index and variables
+    *
+    * @param array      $hostSettings array of host settings, generated from getHostSettings() or equivalent from the API clients
+    * @param int        $hostIndex    index of the host settings
+    * @param array|null $variables    hash of variable and the corresponding value (optional)
+    * @return string URL based on host settings
+    */
+    public static function getHostString(array $hostSettings, $hostIndex, ?array $variables = null)
+    {
+        if (null === $variables) {
+            $variables = [];
+        }
+
+        // check array index out of bound
+        if ($hostIndex < 0 || $hostIndex >= count($hostSettings)) {
+            throw new \InvalidArgumentException("Invalid index $hostIndex when selecting the host. Must be less than ".count($hostSettings));
+        }
+
+        $host = $hostSettings[$hostIndex];
+        $url = $host["url"];
+
+        // go through variable and assign a value
+        foreach ($host["variables"] ?? [] as $name => $variable) {
+            if (array_key_exists($name, $variables)) { // check to see if it's in the variables provided by the user
+                if (!isset($variable['enum_values']) || in_array($variables[$name], $variable["enum_values"], true)) { // check to see if the value is in the enum
+                    $url = str_replace("{".$name."}", $variables[$name], $url);
+                } else {
+                    throw new \InvalidArgumentException("The variable `$name` in the host URL has invalid value ".$variables[$name].". Must be ".join(',', $variable["enum_values"]).".");
+                }
+            } else {
+                // use default value
+                $url = str_replace("{".$name."}", $variable["default_value"], $url);
+            }
+        }
+
+        return $url;
+    }
+
+    /**
+     * Returns URL based on the index and variables
+     *
+     * @param int        $index     index of the host settings
+     * @param array|null $variables hash of variable and the corresponding value (optional)
+     * @return string URL based on host settings
+     */
+    public function getHostFromSettings($index, $variables = null)
+    {
+        return self::getHostString($this->getHostSettings(), $index, $variables);
+    }
+}

+ 246 - 0
api-test/client/lib/FormDataProcessor.php

@@ -0,0 +1,246 @@
+<?php
+/**
+ * FormDataProcessor
+ * PHP version 7.4
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi;
+
+use ArrayAccess;
+use DateTime;
+use GuzzleHttp\Psr7\Utils;
+use Psr\Http\Message\StreamInterface;
+use SplFileObject;
+use WikipaliApi\Model\ModelInterface;
+
+/**
+ * FormDataProcessor Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class FormDataProcessor
+{
+    /**
+     * Tags whether payload passed to ::prepare() contains one or more
+     * SplFileObject or stream values.
+     */
+    public bool $has_file = false;
+
+    /**
+     * Take value and turn it into an array suitable for inclusion in
+     * the http body (form parameter). If it's a string, pass through unchanged
+     * If it's a datetime object, format it in ISO8601
+     *
+     * @param array<string|bool|array|DateTime|ArrayAccess|SplFileObject> $values the value of the form parameter
+     *
+     * @return array [key => value] of formdata
+     */
+    public function prepare(array $values): array
+    {
+        $this->has_file = false;
+        $result = [];
+
+        foreach ($values as $k => $v) {
+            if ($v === null) {
+                continue;
+            }
+
+            $result[$k] = $this->makeFormSafe($v);
+        }
+
+        return $result;
+    }
+
+    /**
+     * Flattens a multi-level array of data and generates a single-level array
+     * compatible with formdata - a single-level array where the keys use bracket
+     * notation to signify nested data.
+     *
+     * credit: https://github.com/FranBar1966/FlatPHP
+     */
+    public static function flatten(array $source, string $start = ''): array
+    {
+        $opt = [
+            'prefix'          => '[',
+            'suffix'          => ']',
+            'suffix-end'      => true,
+            'prefix-list'     => '[',
+            'suffix-list'     => ']',
+            'suffix-list-end' => true,
+        ];
+
+        if ($start === '') {
+            $currentPrefix    = '';
+            $currentSuffix    = '';
+            $currentSuffixEnd = false;
+        } elseif (array_is_list($source)) {
+            $currentPrefix    = $opt['prefix-list'];
+            $currentSuffix    = $opt['suffix-list'];
+            $currentSuffixEnd = $opt['suffix-list-end'];
+        } else {
+            $currentPrefix    = $opt['prefix'];
+            $currentSuffix    = $opt['suffix'];
+            $currentSuffixEnd = $opt['suffix-end'];
+        }
+
+        $currentName = $start;
+        $result = [];
+
+        foreach ($source as $key => $val) {
+            $currentName .= $currentPrefix.$key;
+
+            if (is_array($val) && !empty($val)) {
+                $currentName .= $currentSuffix;
+                $result += self::flatten($val, $currentName);
+            } else {
+                if ($currentSuffixEnd) {
+                    $currentName .= $currentSuffix;
+                }
+
+                if (is_resource($val)) {
+                    $result[$currentName] = $val;
+                } else {
+                    $result[$currentName] = ObjectSerializer::toString($val);
+                }
+            }
+
+            $currentName = $start;
+        }
+
+        return $result;
+    }
+
+    /**
+     * formdata must be limited to scalars or arrays of scalar values,
+     * or a resource for a file upload. Here we iterate through all available
+     * data and identify how to handle each scenario
+     */
+    protected function makeFormSafe($value)
+    {
+        if ($value instanceof SplFileObject) {
+            return $this->processFiles([$value])[0];
+        }
+
+        if (is_resource($value)) {
+            $this->has_file = true;
+
+            return $value;
+        }
+
+        if ($value instanceof ModelInterface) {
+            return $this->processModel($value);
+        }
+
+        if (is_array($value) || (is_object($value) && !$value instanceof \DateTimeInterface)) {
+            $data = [];
+
+            foreach ($value as $k => $v) {
+                $data[$k] = $this->makeFormSafe($v);
+            }
+
+            return $data;
+        }
+
+        return ObjectSerializer::toString($value);
+    }
+
+    /**
+     * We are able to handle nested ModelInterface. We do not simply call
+     * json_decode(json_encode()) because any given model may have binary data
+     * or other data that cannot be serialized to a JSON string
+     */
+    protected function processModel(ModelInterface $model): array
+    {
+        $result = [];
+
+        foreach ($model::openAPITypes() as $name => $type) {
+            $value = $model->offsetGet($name);
+
+            if ($value === null) {
+                continue;
+            }
+
+            if (strpos($type, '\SplFileObject') !== false) {
+                $file = is_array($value) ? $value : [$value];
+                $result[$name] = $this->processFiles($file);
+
+                continue;
+            }
+
+            if ($value instanceof ModelInterface) {
+                $result[$name] = $this->processModel($value);
+
+                continue;
+            }
+
+            if (is_array($value) || is_object($value)) {
+                $result[$name] = $this->makeFormSafe($value);
+
+                continue;
+            }
+
+            $result[$name] = ObjectSerializer::toString($value);
+        }
+
+        return $result;
+    }
+
+    /**
+     * Handle file data
+     */
+    protected function processFiles(array $files): array
+    {
+        $this->has_file = true;
+
+        $result = [];
+
+        foreach ($files as $i => $file) {
+            if (is_array($file)) {
+                $result[$i] = $this->processFiles($file);
+
+                continue;
+            }
+
+            if ($file instanceof StreamInterface) {
+                $result[$i] = $file;
+
+                continue;
+            }
+
+            if ($file instanceof SplFileObject) {
+                $result[$i] = $this->tryFopen($file);
+            }
+        }
+
+        return $result;
+    }
+
+    private function tryFopen(SplFileObject $file)
+    {
+        return Utils::tryFopen($file->getRealPath(), 'rb');
+    }
+}

+ 273 - 0
api-test/client/lib/HeaderSelector.php

@@ -0,0 +1,273 @@
+<?php
+/**
+ * HeaderSelector
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi;
+
+/**
+ * HeaderSelector Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class HeaderSelector
+{
+    /**
+     * @param string[] $accept
+     * @param string   $contentType
+     * @param bool     $isMultipart
+     * @return string[]
+     */
+    public function selectHeaders(array $accept, string $contentType, bool $isMultipart): array
+    {
+        $headers = [];
+
+        $accept = $this->selectAcceptHeader($accept);
+        if ($accept !== null) {
+            $headers['Accept'] = $accept;
+        }
+
+        if (!$isMultipart) {
+            if($contentType === '') {
+                $contentType = 'application/json';
+            }
+
+            $headers['Content-Type'] = $contentType;
+        }
+
+        return $headers;
+    }
+
+    /**
+     * Return the header 'Accept' based on an array of Accept provided.
+     *
+     * @param string[] $accept Array of header
+     *
+     * @return null|string Accept (e.g. application/json)
+     */
+    private function selectAcceptHeader(array $accept): ?string
+    {
+        # filter out empty entries
+        $accept = array_filter($accept);
+
+        if (count($accept) === 0) {
+            return null;
+        }
+
+        # If there's only one Accept header, just use it
+        if (count($accept) === 1) {
+            return reset($accept);
+        }
+
+        # If none of the available Accept headers is of type "json", then just use all them
+        $headersWithJson = $this->selectJsonMimeList($accept);
+        if (count($headersWithJson) === 0) {
+            return implode(',', $accept);
+        }
+
+        # If we got here, then we need add quality values (weight), as described in IETF RFC 9110, Items 12.4.2/12.5.1,
+        # to give the highest priority to json-like headers - recalculating the existing ones, if needed
+        return $this->getAcceptHeaderWithAdjustedWeight($accept, $headersWithJson);
+    }
+
+    /**
+    * Detects whether a string contains a valid JSON mime type
+    *
+    * @param string $searchString
+    * @return bool
+    */
+    public function isJsonMime(string $searchString): bool
+    {
+        return preg_match('~^application/(json|[\w!#$&.+-^_]+\+json)\s*(;|$)~', $searchString) === 1;
+    }
+    
+    /**
+    * Select all items from a list containing a JSON mime type
+    *
+    * @param array $mimeList
+    * @return array
+    */
+    private function selectJsonMimeList(array $mimeList): array {
+        $jsonMimeList = [];
+        foreach ($mimeList as $mime) {
+            if($this->isJsonMime($mime)) {
+                $jsonMimeList[] = $mime;
+            }
+        }
+        return $jsonMimeList;
+    }
+
+
+    /**
+    * Create an Accept header string from the given "Accept" headers array, recalculating all weights
+    *
+    * @param string[] $accept            Array of Accept Headers
+    * @param string[] $headersWithJson   Array of Accept Headers of type "json"
+    *
+    * @return string "Accept" Header (e.g. "application/json, text/html; q=0.9")
+    */
+    private function getAcceptHeaderWithAdjustedWeight(array $accept, array $headersWithJson): string
+    {
+        $processedHeaders = [
+            'withApplicationJson' => [],
+            'withJson' => [],
+            'withoutJson' => [],
+        ];
+
+        foreach ($accept as $header) {
+
+            $headerData = $this->getHeaderAndWeight($header);
+
+            if (stripos($headerData['header'], 'application/json') === 0) {
+                $processedHeaders['withApplicationJson'][] = $headerData;
+            } elseif (in_array($header, $headersWithJson, true)) {
+                $processedHeaders['withJson'][] = $headerData;
+            } else {
+                $processedHeaders['withoutJson'][] = $headerData;
+            }
+        }
+
+        $acceptHeaders = [];
+        $currentWeight = 1000;
+
+        $hasMoreThan28Headers = count($accept) > 28;
+
+        foreach($processedHeaders as $headers) {
+            if (count($headers) > 0) {
+                $acceptHeaders[] = $this->adjustWeight($headers, $currentWeight, $hasMoreThan28Headers);
+            }
+        }
+
+        $acceptHeaders = array_merge(...$acceptHeaders);
+
+        return implode(',', $acceptHeaders);
+    }
+
+    /**
+     * Given an Accept header, returns an associative array splitting the header and its weight
+     *
+     * @param string $header "Accept" Header
+     *
+     * @return array with the header and its weight
+     */
+    private function getHeaderAndWeight(string $header): array
+    {
+        # matches headers with weight, splitting the header and the weight in $outputArray
+        if (preg_match('/(.*);\s*q=(1(?:\.0+)?|0\.\d+)$/', $header, $outputArray) === 1) {
+            $headerData = [
+                'header' => $outputArray[1],
+                'weight' => (int)($outputArray[2] * 1000),
+            ];
+        } else {
+            $headerData = [
+                'header' => trim($header),
+                'weight' => 1000,
+            ];
+        }
+
+        return $headerData;
+    }
+
+    /**
+     * @param array[] $headers
+     * @param float   $currentWeight
+     * @param bool    $hasMoreThan28Headers
+     * @return string[] array of adjusted "Accept" headers
+     */
+    private function adjustWeight(array $headers, float &$currentWeight, bool $hasMoreThan28Headers): array
+    {
+        usort($headers, function (array $a, array $b) {
+            return $b['weight'] - $a['weight'];
+        });
+
+        $acceptHeaders = [];
+        foreach ($headers as $index => $header) {
+            if($index > 0 && $headers[$index - 1]['weight'] > $header['weight'])
+            {
+                $currentWeight = $this->getNextWeight($currentWeight, $hasMoreThan28Headers);
+            }
+
+            $weight = $currentWeight;
+
+            $acceptHeaders[] = $this->buildAcceptHeader($header['header'], $weight);
+        }
+
+        $currentWeight = $this->getNextWeight($currentWeight, $hasMoreThan28Headers);
+
+        return $acceptHeaders;
+    }
+
+    /**
+     * @param string $header
+     * @param int    $weight
+     * @return string
+     */
+    private function buildAcceptHeader(string $header, int $weight): string
+    {
+        if($weight === 1000) {
+            return $header;
+        }
+
+        return trim($header, '; ') . ';q=' . rtrim(sprintf('%0.3f', $weight / 1000), '0');
+    }
+
+    /**
+     * Calculate the next weight, based on the current one.
+     *
+     * If there are less than 28 "Accept" headers, the weights will be decreased by 1 on its highest significant digit, using the
+     * following formula:
+     *
+     *    next weight = current weight - 10 ^ (floor(log(current weight - 1)))
+     *
+     *    ( current weight minus ( 10 raised to the power of ( floor of (log to the base 10 of ( current weight minus 1 ) ) ) ) )
+     *
+     * Starting from 1000, this generates the following series:
+     *
+     * 1000, 900, 800, 700, 600, 500, 400, 300, 200, 100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
+     *
+     * The resulting quality codes are closer to the average "normal" usage of them (like "q=0.9", "q=0.8" and so on), but it only works
+     * if there is a maximum of 28 "Accept" headers. If we have more than that (which is extremely unlikely), then we fall back to a 1-by-1
+     * decrement rule, which will result in quality codes like "q=0.999", "q=0.998" etc.
+     *
+     * @param int  $currentWeight varying from 1 to 1000 (will be divided by 1000 to build the quality value)
+     * @param bool $hasMoreThan28Headers
+     * @return int
+     */
+    public function getNextWeight(int $currentWeight, bool $hasMoreThan28Headers): int
+    {
+        if ($currentWeight <= 1) {
+            return 1;
+        }
+
+        if ($hasMoreThan28Headers) {
+            return $currentWeight - 1;
+        }
+
+        return $currentWeight - 10 ** floor( log10($currentWeight - 1) );
+    }
+}

+ 409 - 0
api-test/client/lib/Model/GetApiV3Heartbeat200Response.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * GetApiV3Heartbeat200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3Heartbeat200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3Heartbeat200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_heartbeat_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3Heartbeat200ResponseData'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3Heartbeat200ResponseData|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3Heartbeat200ResponseData|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3Heartbeat200ResponseData.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3Heartbeat200ResponseData
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3Heartbeat200ResponseData Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3Heartbeat200ResponseData implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_heartbeat_200_response_data';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'status' => 'string',
+        'checked_at' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'status' => null,
+        'checked_at' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'status' => false,
+        'checked_at' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'status' => 'status',
+        'checked_at' => 'checked_at'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'status' => 'setStatus',
+        'checked_at' => 'setCheckedAt'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'status' => 'getStatus',
+        'checked_at' => 'getCheckedAt'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('status', $data ?? [], null);
+        $this->setIfExists('checked_at', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets status
+     *
+     * @return string|null
+     */
+    public function getStatus()
+    {
+        return $this->container['status'];
+    }
+
+    /**
+     * Sets status
+     *
+     * @param string|null $status status
+     *
+     * @return self
+     */
+    public function setStatus($status)
+    {
+        if (is_null($status)) {
+            throw new \InvalidArgumentException('non-nullable status cannot be null');
+        }
+        $this->container['status'] = $status;
+
+        return $this;
+    }
+
+    /**
+     * Gets checked_at
+     *
+     * @return string|null
+     */
+    public function getCheckedAt()
+    {
+        return $this->container['checked_at'];
+    }
+
+    /**
+     * Sets checked_at
+     *
+     * @param string|null $checked_at checked_at
+     *
+     * @return self
+     */
+    public function setCheckedAt($checked_at)
+    {
+        if (is_null($checked_at)) {
+            throw new \InvalidArgumentException('non-nullable checked_at cannot be null');
+        }
+        $this->container['checked_at'] = $checked_at;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3MeReactions200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3MeReactions200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3MeReactions200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3MeReactions200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_me_reactions_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\PaginationMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\PaginationMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\PaginationMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 661 - 0
api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInner.php

@@ -0,0 +1,661 @@
+<?php
+/**
+ * GetApiV3MeReactions200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3MeReactions200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3MeReactions200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_me_reactions_200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'id' => 'string',
+        'type' => 'string',
+        'target_id' => 'string',
+        'target_type' => 'string',
+        'context' => 'string',
+        'created_at' => 'string',
+        'updated_at' => 'string',
+        'user' => '\WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInnerUser'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'id' => null,
+        'type' => null,
+        'target_id' => null,
+        'target_type' => null,
+        'context' => null,
+        'created_at' => null,
+        'updated_at' => null,
+        'user' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'id' => false,
+        'type' => false,
+        'target_id' => false,
+        'target_type' => false,
+        'context' => true,
+        'created_at' => false,
+        'updated_at' => false,
+        'user' => true
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'id' => 'id',
+        'type' => 'type',
+        'target_id' => 'target_id',
+        'target_type' => 'target_type',
+        'context' => 'context',
+        'created_at' => 'created_at',
+        'updated_at' => 'updated_at',
+        'user' => 'user'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'id' => 'setId',
+        'type' => 'setType',
+        'target_id' => 'setTargetId',
+        'target_type' => 'setTargetType',
+        'context' => 'setContext',
+        'created_at' => 'setCreatedAt',
+        'updated_at' => 'setUpdatedAt',
+        'user' => 'setUser'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'id' => 'getId',
+        'type' => 'getType',
+        'target_id' => 'getTargetId',
+        'target_type' => 'getTargetType',
+        'context' => 'getContext',
+        'created_at' => 'getCreatedAt',
+        'updated_at' => 'getUpdatedAt',
+        'user' => 'getUser'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('id', $data ?? [], null);
+        $this->setIfExists('type', $data ?? [], null);
+        $this->setIfExists('target_id', $data ?? [], null);
+        $this->setIfExists('target_type', $data ?? [], null);
+        $this->setIfExists('context', $data ?? [], null);
+        $this->setIfExists('created_at', $data ?? [], null);
+        $this->setIfExists('updated_at', $data ?? [], null);
+        $this->setIfExists('user', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets id
+     *
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->container['id'];
+    }
+
+    /**
+     * Sets id
+     *
+     * @param string|null $id id
+     *
+     * @return self
+     */
+    public function setId($id)
+    {
+        if (is_null($id)) {
+            throw new \InvalidArgumentException('non-nullable id cannot be null');
+        }
+        $this->container['id'] = $id;
+
+        return $this;
+    }
+
+    /**
+     * Gets type
+     *
+     * @return string|null
+     */
+    public function getType()
+    {
+        return $this->container['type'];
+    }
+
+    /**
+     * Sets type
+     *
+     * @param string|null $type type
+     *
+     * @return self
+     */
+    public function setType($type)
+    {
+        if (is_null($type)) {
+            throw new \InvalidArgumentException('non-nullable type cannot be null');
+        }
+        $this->container['type'] = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets target_id
+     *
+     * @return string|null
+     */
+    public function getTargetId()
+    {
+        return $this->container['target_id'];
+    }
+
+    /**
+     * Sets target_id
+     *
+     * @param string|null $target_id target_id
+     *
+     * @return self
+     */
+    public function setTargetId($target_id)
+    {
+        if (is_null($target_id)) {
+            throw new \InvalidArgumentException('non-nullable target_id cannot be null');
+        }
+        $this->container['target_id'] = $target_id;
+
+        return $this;
+    }
+
+    /**
+     * Gets target_type
+     *
+     * @return string|null
+     */
+    public function getTargetType()
+    {
+        return $this->container['target_type'];
+    }
+
+    /**
+     * Sets target_type
+     *
+     * @param string|null $target_type target_type
+     *
+     * @return self
+     */
+    public function setTargetType($target_type)
+    {
+        if (is_null($target_type)) {
+            throw new \InvalidArgumentException('non-nullable target_type cannot be null');
+        }
+        $this->container['target_type'] = $target_type;
+
+        return $this;
+    }
+
+    /**
+     * Gets context
+     *
+     * @return string|null
+     */
+    public function getContext()
+    {
+        return $this->container['context'];
+    }
+
+    /**
+     * Sets context
+     *
+     * @param string|null $context context
+     *
+     * @return self
+     */
+    public function setContext($context)
+    {
+        if (is_null($context)) {
+            array_push($this->openAPINullablesSetToNull, 'context');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('context', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['context'] = $context;
+
+        return $this;
+    }
+
+    /**
+     * Gets created_at
+     *
+     * @return string|null
+     */
+    public function getCreatedAt()
+    {
+        return $this->container['created_at'];
+    }
+
+    /**
+     * Sets created_at
+     *
+     * @param string|null $created_at created_at
+     *
+     * @return self
+     */
+    public function setCreatedAt($created_at)
+    {
+        if (is_null($created_at)) {
+            throw new \InvalidArgumentException('non-nullable created_at cannot be null');
+        }
+        $this->container['created_at'] = $created_at;
+
+        return $this;
+    }
+
+    /**
+     * Gets updated_at
+     *
+     * @return string|null
+     */
+    public function getUpdatedAt()
+    {
+        return $this->container['updated_at'];
+    }
+
+    /**
+     * Sets updated_at
+     *
+     * @param string|null $updated_at updated_at
+     *
+     * @return self
+     */
+    public function setUpdatedAt($updated_at)
+    {
+        if (is_null($updated_at)) {
+            throw new \InvalidArgumentException('non-nullable updated_at cannot be null');
+        }
+        $this->container['updated_at'] = $updated_at;
+
+        return $this;
+    }
+
+    /**
+     * Gets user
+     *
+     * @return \WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInnerUser|null
+     */
+    public function getUser()
+    {
+        return $this->container['user'];
+    }
+
+    /**
+     * Sets user
+     *
+     * @param \WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInnerUser|null $user user
+     *
+     * @return self
+     */
+    public function setUser($user)
+    {
+        if (is_null($user)) {
+            array_push($this->openAPINullablesSetToNull, 'user');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('user', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['user'] = $user;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 613 - 0
api-test/client/lib/Model/GetApiV3MeReactions200ResponseDataInnerUser.php

@@ -0,0 +1,613 @@
+<?php
+/**
+ * GetApiV3MeReactions200ResponseDataInnerUser
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3MeReactions200ResponseDataInnerUser Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3MeReactions200ResponseDataInnerUser implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_me_reactions_200_response_data_inner_user';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'id' => 'string',
+        'nick_name' => 'string',
+        'user_name' => 'string',
+        'real_name' => 'string',
+        'sn' => 'int',
+        'avatar' => 'string',
+        'roles' => 'string[]'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'id' => null,
+        'nick_name' => null,
+        'user_name' => null,
+        'real_name' => null,
+        'sn' => null,
+        'avatar' => null,
+        'roles' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'id' => false,
+        'nick_name' => false,
+        'user_name' => false,
+        'real_name' => false,
+        'sn' => false,
+        'avatar' => false,
+        'roles' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'id' => 'id',
+        'nick_name' => 'nickName',
+        'user_name' => 'userName',
+        'real_name' => 'realName',
+        'sn' => 'sn',
+        'avatar' => 'avatar',
+        'roles' => 'roles'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'id' => 'setId',
+        'nick_name' => 'setNickName',
+        'user_name' => 'setUserName',
+        'real_name' => 'setRealName',
+        'sn' => 'setSn',
+        'avatar' => 'setAvatar',
+        'roles' => 'setRoles'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'id' => 'getId',
+        'nick_name' => 'getNickName',
+        'user_name' => 'getUserName',
+        'real_name' => 'getRealName',
+        'sn' => 'getSn',
+        'avatar' => 'getAvatar',
+        'roles' => 'getRoles'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('id', $data ?? [], null);
+        $this->setIfExists('nick_name', $data ?? [], null);
+        $this->setIfExists('user_name', $data ?? [], null);
+        $this->setIfExists('real_name', $data ?? [], null);
+        $this->setIfExists('sn', $data ?? [], null);
+        $this->setIfExists('avatar', $data ?? [], null);
+        $this->setIfExists('roles', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets id
+     *
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->container['id'];
+    }
+
+    /**
+     * Sets id
+     *
+     * @param string|null $id id
+     *
+     * @return self
+     */
+    public function setId($id)
+    {
+        if (is_null($id)) {
+            throw new \InvalidArgumentException('non-nullable id cannot be null');
+        }
+        $this->container['id'] = $id;
+
+        return $this;
+    }
+
+    /**
+     * Gets nick_name
+     *
+     * @return string|null
+     */
+    public function getNickName()
+    {
+        return $this->container['nick_name'];
+    }
+
+    /**
+     * Sets nick_name
+     *
+     * @param string|null $nick_name nick_name
+     *
+     * @return self
+     */
+    public function setNickName($nick_name)
+    {
+        if (is_null($nick_name)) {
+            throw new \InvalidArgumentException('non-nullable nick_name cannot be null');
+        }
+        $this->container['nick_name'] = $nick_name;
+
+        return $this;
+    }
+
+    /**
+     * Gets user_name
+     *
+     * @return string|null
+     */
+    public function getUserName()
+    {
+        return $this->container['user_name'];
+    }
+
+    /**
+     * Sets user_name
+     *
+     * @param string|null $user_name user_name
+     *
+     * @return self
+     */
+    public function setUserName($user_name)
+    {
+        if (is_null($user_name)) {
+            throw new \InvalidArgumentException('non-nullable user_name cannot be null');
+        }
+        $this->container['user_name'] = $user_name;
+
+        return $this;
+    }
+
+    /**
+     * Gets real_name
+     *
+     * @return string|null
+     */
+    public function getRealName()
+    {
+        return $this->container['real_name'];
+    }
+
+    /**
+     * Sets real_name
+     *
+     * @param string|null $real_name real_name
+     *
+     * @return self
+     */
+    public function setRealName($real_name)
+    {
+        if (is_null($real_name)) {
+            throw new \InvalidArgumentException('non-nullable real_name cannot be null');
+        }
+        $this->container['real_name'] = $real_name;
+
+        return $this;
+    }
+
+    /**
+     * Gets sn
+     *
+     * @return int|null
+     */
+    public function getSn()
+    {
+        return $this->container['sn'];
+    }
+
+    /**
+     * Sets sn
+     *
+     * @param int|null $sn sn
+     *
+     * @return self
+     */
+    public function setSn($sn)
+    {
+        if (is_null($sn)) {
+            throw new \InvalidArgumentException('non-nullable sn cannot be null');
+        }
+        $this->container['sn'] = $sn;
+
+        return $this;
+    }
+
+    /**
+     * Gets avatar
+     *
+     * @return string|null
+     */
+    public function getAvatar()
+    {
+        return $this->container['avatar'];
+    }
+
+    /**
+     * Sets avatar
+     *
+     * @param string|null $avatar avatar
+     *
+     * @return self
+     */
+    public function setAvatar($avatar)
+    {
+        if (is_null($avatar)) {
+            throw new \InvalidArgumentException('non-nullable avatar cannot be null');
+        }
+        $this->container['avatar'] = $avatar;
+
+        return $this;
+    }
+
+    /**
+     * Gets roles
+     *
+     * @return string[]|null
+     */
+    public function getRoles()
+    {
+        return $this->container['roles'];
+    }
+
+    /**
+     * Sets roles
+     *
+     * @param string[]|null $roles roles
+     *
+     * @return self
+     */
+    public function setRoles($roles)
+    {
+        if (is_null($roles)) {
+            throw new \InvalidArgumentException('non-nullable roles cannot be null');
+        }
+        $this->container['roles'] = $roles;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3Progress200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3Progress200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3Progress200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3Progress200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_progress_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3Progress200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\PaginationMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3Progress200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3Progress200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\PaginationMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\PaginationMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 702 - 0
api-test/client/lib/Model/GetApiV3Progress200ResponseDataInner.php

@@ -0,0 +1,702 @@
+<?php
+/**
+ * GetApiV3Progress200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3Progress200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3Progress200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_progress_200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'book' => 'int',
+        'para' => 'int',
+        'lang' => 'string',
+        'progress' => 'float',
+        'channel_id' => 'string',
+        'title' => 'string',
+        'last_chapter_completed_at' => 'string',
+        'completed_at' => 'string',
+        'updated_at' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'book' => null,
+        'para' => null,
+        'lang' => null,
+        'progress' => null,
+        'channel_id' => null,
+        'title' => null,
+        'last_chapter_completed_at' => null,
+        'completed_at' => null,
+        'updated_at' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'book' => false,
+        'para' => false,
+        'lang' => false,
+        'progress' => false,
+        'channel_id' => false,
+        'title' => true,
+        'last_chapter_completed_at' => true,
+        'completed_at' => true,
+        'updated_at' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'book' => 'book',
+        'para' => 'para',
+        'lang' => 'lang',
+        'progress' => 'progress',
+        'channel_id' => 'channel_id',
+        'title' => 'title',
+        'last_chapter_completed_at' => 'last_chapter_completed_at',
+        'completed_at' => 'completed_at',
+        'updated_at' => 'updated_at'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'book' => 'setBook',
+        'para' => 'setPara',
+        'lang' => 'setLang',
+        'progress' => 'setProgress',
+        'channel_id' => 'setChannelId',
+        'title' => 'setTitle',
+        'last_chapter_completed_at' => 'setLastChapterCompletedAt',
+        'completed_at' => 'setCompletedAt',
+        'updated_at' => 'setUpdatedAt'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'book' => 'getBook',
+        'para' => 'getPara',
+        'lang' => 'getLang',
+        'progress' => 'getProgress',
+        'channel_id' => 'getChannelId',
+        'title' => 'getTitle',
+        'last_chapter_completed_at' => 'getLastChapterCompletedAt',
+        'completed_at' => 'getCompletedAt',
+        'updated_at' => 'getUpdatedAt'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('book', $data ?? [], null);
+        $this->setIfExists('para', $data ?? [], null);
+        $this->setIfExists('lang', $data ?? [], null);
+        $this->setIfExists('progress', $data ?? [], null);
+        $this->setIfExists('channel_id', $data ?? [], null);
+        $this->setIfExists('title', $data ?? [], null);
+        $this->setIfExists('last_chapter_completed_at', $data ?? [], null);
+        $this->setIfExists('completed_at', $data ?? [], null);
+        $this->setIfExists('updated_at', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets book
+     *
+     * @return int|null
+     */
+    public function getBook()
+    {
+        return $this->container['book'];
+    }
+
+    /**
+     * Sets book
+     *
+     * @param int|null $book book
+     *
+     * @return self
+     */
+    public function setBook($book)
+    {
+        if (is_null($book)) {
+            throw new \InvalidArgumentException('non-nullable book cannot be null');
+        }
+        $this->container['book'] = $book;
+
+        return $this;
+    }
+
+    /**
+     * Gets para
+     *
+     * @return int|null
+     */
+    public function getPara()
+    {
+        return $this->container['para'];
+    }
+
+    /**
+     * Sets para
+     *
+     * @param int|null $para para
+     *
+     * @return self
+     */
+    public function setPara($para)
+    {
+        if (is_null($para)) {
+            throw new \InvalidArgumentException('non-nullable para cannot be null');
+        }
+        $this->container['para'] = $para;
+
+        return $this;
+    }
+
+    /**
+     * Gets lang
+     *
+     * @return string|null
+     */
+    public function getLang()
+    {
+        return $this->container['lang'];
+    }
+
+    /**
+     * Sets lang
+     *
+     * @param string|null $lang lang
+     *
+     * @return self
+     */
+    public function setLang($lang)
+    {
+        if (is_null($lang)) {
+            throw new \InvalidArgumentException('non-nullable lang cannot be null');
+        }
+        $this->container['lang'] = $lang;
+
+        return $this;
+    }
+
+    /**
+     * Gets progress
+     *
+     * @return float|null
+     */
+    public function getProgress()
+    {
+        return $this->container['progress'];
+    }
+
+    /**
+     * Sets progress
+     *
+     * @param float|null $progress progress
+     *
+     * @return self
+     */
+    public function setProgress($progress)
+    {
+        if (is_null($progress)) {
+            throw new \InvalidArgumentException('non-nullable progress cannot be null');
+        }
+        $this->container['progress'] = $progress;
+
+        return $this;
+    }
+
+    /**
+     * Gets channel_id
+     *
+     * @return string|null
+     */
+    public function getChannelId()
+    {
+        return $this->container['channel_id'];
+    }
+
+    /**
+     * Sets channel_id
+     *
+     * @param string|null $channel_id channel_id
+     *
+     * @return self
+     */
+    public function setChannelId($channel_id)
+    {
+        if (is_null($channel_id)) {
+            throw new \InvalidArgumentException('non-nullable channel_id cannot be null');
+        }
+        $this->container['channel_id'] = $channel_id;
+
+        return $this;
+    }
+
+    /**
+     * Gets title
+     *
+     * @return string|null
+     */
+    public function getTitle()
+    {
+        return $this->container['title'];
+    }
+
+    /**
+     * Sets title
+     *
+     * @param string|null $title title
+     *
+     * @return self
+     */
+    public function setTitle($title)
+    {
+        if (is_null($title)) {
+            array_push($this->openAPINullablesSetToNull, 'title');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('title', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['title'] = $title;
+
+        return $this;
+    }
+
+    /**
+     * Gets last_chapter_completed_at
+     *
+     * @return string|null
+     */
+    public function getLastChapterCompletedAt()
+    {
+        return $this->container['last_chapter_completed_at'];
+    }
+
+    /**
+     * Sets last_chapter_completed_at
+     *
+     * @param string|null $last_chapter_completed_at last_chapter_completed_at
+     *
+     * @return self
+     */
+    public function setLastChapterCompletedAt($last_chapter_completed_at)
+    {
+        if (is_null($last_chapter_completed_at)) {
+            array_push($this->openAPINullablesSetToNull, 'last_chapter_completed_at');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('last_chapter_completed_at', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['last_chapter_completed_at'] = $last_chapter_completed_at;
+
+        return $this;
+    }
+
+    /**
+     * Gets completed_at
+     *
+     * @return string|null
+     */
+    public function getCompletedAt()
+    {
+        return $this->container['completed_at'];
+    }
+
+    /**
+     * Sets completed_at
+     *
+     * @param string|null $completed_at completed_at
+     *
+     * @return self
+     */
+    public function setCompletedAt($completed_at)
+    {
+        if (is_null($completed_at)) {
+            array_push($this->openAPINullablesSetToNull, 'completed_at');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('completed_at', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['completed_at'] = $completed_at;
+
+        return $this;
+    }
+
+    /**
+     * Gets updated_at
+     *
+     * @return string|null
+     */
+    public function getUpdatedAt()
+    {
+        return $this->container['updated_at'];
+    }
+
+    /**
+     * Sets updated_at
+     *
+     * @param string|null $updated_at updated_at
+     *
+     * @return self
+     */
+    public function setUpdatedAt($updated_at)
+    {
+        if (is_null($updated_at)) {
+            throw new \InvalidArgumentException('non-nullable updated_at cannot be null');
+        }
+        $this->container['updated_at'] = $updated_at;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3ReactionsTally200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3ReactionsTally200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3ReactionsTally200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3ReactionsTally200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_reactions_tally_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3ReactionsTally200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\PaginationMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3ReactionsTally200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3ReactionsTally200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\PaginationMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\PaginationMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 518 - 0
api-test/client/lib/Model/GetApiV3ReactionsTally200ResponseDataInner.php

@@ -0,0 +1,518 @@
+<?php
+/**
+ * GetApiV3ReactionsTally200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3ReactionsTally200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3ReactionsTally200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_reactions_tally_200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'type' => 'string',
+        'count' => 'int',
+        'selected' => 'bool',
+        'id' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'type' => null,
+        'count' => null,
+        'selected' => null,
+        'id' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'type' => false,
+        'count' => false,
+        'selected' => false,
+        'id' => true
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'type' => 'type',
+        'count' => 'count',
+        'selected' => 'selected',
+        'id' => 'id'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'type' => 'setType',
+        'count' => 'setCount',
+        'selected' => 'setSelected',
+        'id' => 'setId'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'type' => 'getType',
+        'count' => 'getCount',
+        'selected' => 'getSelected',
+        'id' => 'getId'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('type', $data ?? [], null);
+        $this->setIfExists('count', $data ?? [], null);
+        $this->setIfExists('selected', $data ?? [], null);
+        $this->setIfExists('id', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets type
+     *
+     * @return string|null
+     */
+    public function getType()
+    {
+        return $this->container['type'];
+    }
+
+    /**
+     * Sets type
+     *
+     * @param string|null $type type
+     *
+     * @return self
+     */
+    public function setType($type)
+    {
+        if (is_null($type)) {
+            throw new \InvalidArgumentException('non-nullable type cannot be null');
+        }
+        $this->container['type'] = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets count
+     *
+     * @return int|null
+     */
+    public function getCount()
+    {
+        return $this->container['count'];
+    }
+
+    /**
+     * Sets count
+     *
+     * @param int|null $count count
+     *
+     * @return self
+     */
+    public function setCount($count)
+    {
+        if (is_null($count)) {
+            throw new \InvalidArgumentException('non-nullable count cannot be null');
+        }
+        $this->container['count'] = $count;
+
+        return $this;
+    }
+
+    /**
+     * Gets selected
+     *
+     * @return bool|null
+     */
+    public function getSelected()
+    {
+        return $this->container['selected'];
+    }
+
+    /**
+     * Sets selected
+     *
+     * @param bool|null $selected selected
+     *
+     * @return self
+     */
+    public function setSelected($selected)
+    {
+        if (is_null($selected)) {
+            throw new \InvalidArgumentException('non-nullable selected cannot be null');
+        }
+        $this->container['selected'] = $selected;
+
+        return $this;
+    }
+
+    /**
+     * Gets id
+     *
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->container['id'];
+    }
+
+    /**
+     * Sets id
+     *
+     * @param string|null $id id
+     *
+     * @return self
+     */
+    public function setId($id)
+    {
+        if (is_null($id)) {
+            array_push($this->openAPINullablesSetToNull, 'id');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('id', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['id'] = $id;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3Search200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3Search200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3Search200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3Search200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_search_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'success' => 'bool',
+        'data' => 'object'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'success' => null,
+        'data' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'success' => false,
+        'data' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'success' => 'success',
+        'data' => 'data'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'success' => 'setSuccess',
+        'data' => 'setData'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'success' => 'getSuccess',
+        'data' => 'getData'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('success', $data ?? [], null);
+        $this->setIfExists('data', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets success
+     *
+     * @return bool|null
+     */
+    public function getSuccess()
+    {
+        return $this->container['success'];
+    }
+
+    /**
+     * Sets success
+     *
+     * @param bool|null $success success
+     *
+     * @return self
+     */
+    public function setSuccess($success)
+    {
+        if (is_null($success)) {
+            throw new \InvalidArgumentException('non-nullable success cannot be null');
+        }
+        $this->container['success'] = $success;
+
+        return $this;
+    }
+
+    /**
+     * Gets data
+     *
+     * @return object|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param object|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 409 - 0
api-test/client/lib/Model/GetApiV3SearchSearch200Response.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * GetApiV3SearchSearch200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3SearchSearch200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3SearchSearch200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_search_search__200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => 'object'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return object|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param object|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3TipitakaReadingChannel200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaReadingChannel200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaReadingChannel200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_reading_channel__200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3TipitakaReadingChannel200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\GetApiV3TipitakaReadingChannel200ResponseMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaReadingChannel200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaReadingChannel200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaReadingChannel200ResponseMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaReadingChannel200ResponseMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 477 - 0
api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseDataInner.php

@@ -0,0 +1,477 @@
+<?php
+/**
+ * GetApiV3TipitakaReadingChannel200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaReadingChannel200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaReadingChannel200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_reading_channel__200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'para' => 'int',
+        'display' => 'string',
+        'book' => 'int'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'para' => null,
+        'display' => null,
+        'book' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'para' => false,
+        'display' => false,
+        'book' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'para' => 'para',
+        'display' => 'display',
+        'book' => 'book'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'para' => 'setPara',
+        'display' => 'setDisplay',
+        'book' => 'setBook'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'para' => 'getPara',
+        'display' => 'getDisplay',
+        'book' => 'getBook'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('para', $data ?? [], null);
+        $this->setIfExists('display', $data ?? [], null);
+        $this->setIfExists('book', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets para
+     *
+     * @return int|null
+     */
+    public function getPara()
+    {
+        return $this->container['para'];
+    }
+
+    /**
+     * Sets para
+     *
+     * @param int|null $para para
+     *
+     * @return self
+     */
+    public function setPara($para)
+    {
+        if (is_null($para)) {
+            throw new \InvalidArgumentException('non-nullable para cannot be null');
+        }
+        $this->container['para'] = $para;
+
+        return $this;
+    }
+
+    /**
+     * Gets display
+     *
+     * @return string|null
+     */
+    public function getDisplay()
+    {
+        return $this->container['display'];
+    }
+
+    /**
+     * Sets display
+     *
+     * @param string|null $display display
+     *
+     * @return self
+     */
+    public function setDisplay($display)
+    {
+        if (is_null($display)) {
+            throw new \InvalidArgumentException('non-nullable display cannot be null');
+        }
+        $this->container['display'] = $display;
+
+        return $this;
+    }
+
+    /**
+     * Gets book
+     *
+     * @return int|null
+     */
+    public function getBook()
+    {
+        return $this->container['book'];
+    }
+
+    /**
+     * Sets book
+     *
+     * @param int|null $book book
+     *
+     * @return self
+     */
+    public function setBook($book)
+    {
+        if (is_null($book)) {
+            throw new \InvalidArgumentException('non-nullable book cannot be null');
+        }
+        $this->container['book'] = $book;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 545 - 0
api-test/client/lib/Model/GetApiV3TipitakaReadingChannel200ResponseMeta.php

@@ -0,0 +1,545 @@
+<?php
+/**
+ * GetApiV3TipitakaReadingChannel200ResponseMeta
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaReadingChannel200ResponseMeta Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaReadingChannel200ResponseMeta implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_reading_channel__200_response_meta';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'page_size' => 'int',
+        'page_size_unit' => 'string',
+        'next_cursor' => 'string',
+        'total' => 'int',
+        'remaining' => 'int'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'page_size' => null,
+        'page_size_unit' => null,
+        'next_cursor' => null,
+        'total' => null,
+        'remaining' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'page_size' => false,
+        'page_size_unit' => false,
+        'next_cursor' => false,
+        'total' => false,
+        'remaining' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'page_size' => 'page_size',
+        'page_size_unit' => 'page_size_unit',
+        'next_cursor' => 'next_cursor',
+        'total' => 'total',
+        'remaining' => 'remaining'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'page_size' => 'setPageSize',
+        'page_size_unit' => 'setPageSizeUnit',
+        'next_cursor' => 'setNextCursor',
+        'total' => 'setTotal',
+        'remaining' => 'setRemaining'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'page_size' => 'getPageSize',
+        'page_size_unit' => 'getPageSizeUnit',
+        'next_cursor' => 'getNextCursor',
+        'total' => 'getTotal',
+        'remaining' => 'getRemaining'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('page_size', $data ?? [], null);
+        $this->setIfExists('page_size_unit', $data ?? [], null);
+        $this->setIfExists('next_cursor', $data ?? [], null);
+        $this->setIfExists('total', $data ?? [], null);
+        $this->setIfExists('remaining', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets page_size
+     *
+     * @return int|null
+     */
+    public function getPageSize()
+    {
+        return $this->container['page_size'];
+    }
+
+    /**
+     * Sets page_size
+     *
+     * @param int|null $page_size 每块大小(按 unit 计)
+     *
+     * @return self
+     */
+    public function setPageSize($page_size)
+    {
+        if (is_null($page_size)) {
+            throw new \InvalidArgumentException('non-nullable page_size cannot be null');
+        }
+        $this->container['page_size'] = $page_size;
+
+        return $this;
+    }
+
+    /**
+     * Gets page_size_unit
+     *
+     * @return string|null
+     */
+    public function getPageSizeUnit()
+    {
+        return $this->container['page_size_unit'];
+    }
+
+    /**
+     * Sets page_size_unit
+     *
+     * @param string|null $page_size_unit 每块大小的单位:para 或 byte
+     *
+     * @return self
+     */
+    public function setPageSizeUnit($page_size_unit)
+    {
+        if (is_null($page_size_unit)) {
+            throw new \InvalidArgumentException('non-nullable page_size_unit cannot be null');
+        }
+        $this->container['page_size_unit'] = $page_size_unit;
+
+        return $this;
+    }
+
+    /**
+     * Gets next_cursor
+     *
+     * @return string|null
+     */
+    public function getNextCursor()
+    {
+        return $this->container['next_cursor'];
+    }
+
+    /**
+     * Sets next_cursor
+     *
+     * @param string|null $next_cursor 下一块的游标,原样传回 after;为 null 表示取完
+     *
+     * @return self
+     */
+    public function setNextCursor($next_cursor)
+    {
+        if (is_null($next_cursor)) {
+            throw new \InvalidArgumentException('non-nullable next_cursor cannot be null');
+        }
+        $this->container['next_cursor'] = $next_cursor;
+
+        return $this;
+    }
+
+    /**
+     * Gets total
+     *
+     * @return int|null
+     */
+    public function getTotal()
+    {
+        return $this->container['total'];
+    }
+
+    /**
+     * Sets total
+     *
+     * @param int|null $total 过滤范围内已翻译的段落总数。仅在带了 book 过滤时出现
+     *
+     * @return self
+     */
+    public function setTotal($total)
+    {
+        if (is_null($total)) {
+            throw new \InvalidArgumentException('non-nullable total cannot be null');
+        }
+        $this->container['total'] = $total;
+
+        return $this;
+    }
+
+    /**
+     * Gets remaining
+     *
+     * @return int|null
+     */
+    public function getRemaining()
+    {
+        return $this->container['remaining'];
+    }
+
+    /**
+     * Sets remaining
+     *
+     * @param int|null $remaining 本块之后过滤范围内还剩多少段。仅在带了 book 过滤时出现
+     *
+     * @return self
+     */
+    public function setRemaining($remaining)
+    {
+        if (is_null($remaining)) {
+            throw new \InvalidArgumentException('non-nullable remaining cannot be null');
+        }
+        $this->container['remaining'] = $remaining;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphs200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphs200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphs200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 627 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInner.php

@@ -0,0 +1,627 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphs200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphs200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphs200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'book' => 'int',
+        'book_id' => 'int',
+        'title' => 'string',
+        'cs_para' => 'int',
+        'para' => 'int[]',
+        'tags' => 'string[]',
+        'path' => '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner[]'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'book' => null,
+        'book_id' => null,
+        'title' => null,
+        'cs_para' => null,
+        'para' => null,
+        'tags' => null,
+        'path' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'book' => false,
+        'book_id' => false,
+        'title' => true,
+        'cs_para' => false,
+        'para' => false,
+        'tags' => false,
+        'path' => true
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'book' => 'book',
+        'book_id' => 'book_id',
+        'title' => 'title',
+        'cs_para' => 'cs_para',
+        'para' => 'para',
+        'tags' => 'tags',
+        'path' => 'path'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'book' => 'setBook',
+        'book_id' => 'setBookId',
+        'title' => 'setTitle',
+        'cs_para' => 'setCsPara',
+        'para' => 'setPara',
+        'tags' => 'setTags',
+        'path' => 'setPath'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'book' => 'getBook',
+        'book_id' => 'getBookId',
+        'title' => 'getTitle',
+        'cs_para' => 'getCsPara',
+        'para' => 'getPara',
+        'tags' => 'getTags',
+        'path' => 'getPath'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('book', $data ?? [], null);
+        $this->setIfExists('book_id', $data ?? [], null);
+        $this->setIfExists('title', $data ?? [], null);
+        $this->setIfExists('cs_para', $data ?? [], null);
+        $this->setIfExists('para', $data ?? [], null);
+        $this->setIfExists('tags', $data ?? [], null);
+        $this->setIfExists('path', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets book
+     *
+     * @return int|null
+     */
+    public function getBook()
+    {
+        return $this->container['book'];
+    }
+
+    /**
+     * Sets book
+     *
+     * @param int|null $book book
+     *
+     * @return self
+     */
+    public function setBook($book)
+    {
+        if (is_null($book)) {
+            throw new \InvalidArgumentException('non-nullable book cannot be null');
+        }
+        $this->container['book'] = $book;
+
+        return $this;
+    }
+
+    /**
+     * Gets book_id
+     *
+     * @return int|null
+     */
+    public function getBookId()
+    {
+        return $this->container['book_id'];
+    }
+
+    /**
+     * Sets book_id
+     *
+     * @param int|null $book_id book_id
+     *
+     * @return self
+     */
+    public function setBookId($book_id)
+    {
+        if (is_null($book_id)) {
+            throw new \InvalidArgumentException('non-nullable book_id cannot be null');
+        }
+        $this->container['book_id'] = $book_id;
+
+        return $this;
+    }
+
+    /**
+     * Gets title
+     *
+     * @return string|null
+     */
+    public function getTitle()
+    {
+        return $this->container['title'];
+    }
+
+    /**
+     * Sets title
+     *
+     * @param string|null $title title
+     *
+     * @return self
+     */
+    public function setTitle($title)
+    {
+        if (is_null($title)) {
+            array_push($this->openAPINullablesSetToNull, 'title');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('title', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['title'] = $title;
+
+        return $this;
+    }
+
+    /**
+     * Gets cs_para
+     *
+     * @return int|null
+     */
+    public function getCsPara()
+    {
+        return $this->container['cs_para'];
+    }
+
+    /**
+     * Sets cs_para
+     *
+     * @param int|null $cs_para cs_para
+     *
+     * @return self
+     */
+    public function setCsPara($cs_para)
+    {
+        if (is_null($cs_para)) {
+            throw new \InvalidArgumentException('non-nullable cs_para cannot be null');
+        }
+        $this->container['cs_para'] = $cs_para;
+
+        return $this;
+    }
+
+    /**
+     * Gets para
+     *
+     * @return int[]|null
+     */
+    public function getPara()
+    {
+        return $this->container['para'];
+    }
+
+    /**
+     * Sets para
+     *
+     * @param int[]|null $para para
+     *
+     * @return self
+     */
+    public function setPara($para)
+    {
+        if (is_null($para)) {
+            throw new \InvalidArgumentException('non-nullable para cannot be null');
+        }
+        $this->container['para'] = $para;
+
+        return $this;
+    }
+
+    /**
+     * Gets tags
+     *
+     * @return string[]|null
+     */
+    public function getTags()
+    {
+        return $this->container['tags'];
+    }
+
+    /**
+     * Sets tags
+     *
+     * @param string[]|null $tags tags
+     *
+     * @return self
+     */
+    public function setTags($tags)
+    {
+        if (is_null($tags)) {
+            throw new \InvalidArgumentException('non-nullable tags cannot be null');
+        }
+        $this->container['tags'] = $tags;
+
+        return $this;
+    }
+
+    /**
+     * Gets path
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner[]|null
+     */
+    public function getPath()
+    {
+        return $this->container['path'];
+    }
+
+    /**
+     * Sets path
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner[]|null $path path
+     *
+     * @return self
+     */
+    public function setPath($path)
+    {
+        if (is_null($path)) {
+            array_push($this->openAPINullablesSetToNull, 'path');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('path', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['path'] = $path;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphs200ResponseDataInnerPathInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_200_response_data_inner_path_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'title' => 'string',
+        'level' => 'int'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'title' => null,
+        'level' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'title' => false,
+        'level' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'title' => 'title',
+        'level' => 'level'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'title' => 'setTitle',
+        'level' => 'setLevel'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'title' => 'getTitle',
+        'level' => 'getLevel'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('title', $data ?? [], null);
+        $this->setIfExists('level', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets title
+     *
+     * @return string|null
+     */
+    public function getTitle()
+    {
+        return $this->container['title'];
+    }
+
+    /**
+     * Sets title
+     *
+     * @param string|null $title title
+     *
+     * @return self
+     */
+    public function setTitle($title)
+    {
+        if (is_null($title)) {
+            throw new \InvalidArgumentException('non-nullable title cannot be null');
+        }
+        $this->container['title'] = $title;
+
+        return $this;
+    }
+
+    /**
+     * Gets level
+     *
+     * @return int|null
+     */
+    public function getLevel()
+    {
+        return $this->container['level'];
+    }
+
+    /**
+     * Sets level
+     *
+     * @param int|null $level level
+     *
+     * @return self
+     */
+    public function setLevel($level)
+    {
+        if (is_null($level)) {
+            throw new \InvalidArgumentException('non-nullable level cannot be null');
+        }
+        $this->container['level'] = $level;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 409 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphs200ResponseMeta.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphs200ResponseMeta
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphs200ResponseMeta Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphs200ResponseMeta implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_200_response_meta';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'count' => 'int'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'count' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'count' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'count' => 'count'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'count' => 'setCount'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'count' => 'getCount'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('count', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets count
+     *
+     * @return int|null
+     */
+    public function getCount()
+    {
+        return $this->container['count'];
+    }
+
+    /**
+     * Sets count
+     *
+     * @param int|null $count 命中的书籍条数
+     *
+     * @return self
+     */
+    public function setCount($count)
+    {
+        if (is_null($count)) {
+            throw new \InvalidArgumentException('non-nullable count cannot be null');
+        }
+        $this->container['count'] = $count;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregate200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregate200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphsAggregate200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_aggregate_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\PaginationMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\PaginationMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\PaginationMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 409 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphsAggregate200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_aggregate_200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'book_name' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'book_name' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'book_name' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'book_name' => 'book_name'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'book_name' => 'setBookName'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'book_name' => 'getBookName'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('book_name', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets book_name
+     *
+     * @return string|null
+     */
+    public function getBookName()
+    {
+        return $this->container['book_name'];
+    }
+
+    /**
+     * Sets book_name
+     *
+     * @param string|null $book_name book_name
+     *
+     * @return self
+     */
+    public function setBookName($book_name)
+    {
+        if (is_null($book_name)) {
+            throw new \InvalidArgumentException('non-nullable book_name cannot be null');
+        }
+        $this->container['book_name'] = $book_name;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 443 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphsAggregateBookName200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_aggregate_book_name__200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\PaginationMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\PaginationMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\PaginationMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 409 - 0
api-test/client/lib/Model/GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaRelatedParagraphsAggregateBookName200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_related_paragraphs_aggregate_book_name__200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'cs_para' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'cs_para' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'cs_para' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'cs_para' => 'cs_para'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'cs_para' => 'setCsPara'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'cs_para' => 'getCsPara'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('cs_para', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets cs_para
+     *
+     * @return string|null
+     */
+    public function getCsPara()
+    {
+        return $this->container['cs_para'];
+    }
+
+    /**
+     * Sets cs_para
+     *
+     * @param string|null $cs_para cs_para
+     *
+     * @return self
+     */
+    public function setCsPara($cs_para)
+    {
+        if (is_null($cs_para)) {
+            throw new \InvalidArgumentException('non-nullable cs_para cannot be null');
+        }
+        $this->container['cs_para'] = $cs_para;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 111 - 0
api-test/client/lib/Model/ModelInterface.php

@@ -0,0 +1,111 @@
+<?php
+/**
+ * ModelInterface
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi\Model
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+/**
+ * Interface abstracting model access.
+ *
+ * @package WikipaliApi\Model
+ * @author  OpenAPI Generator team
+ */
+interface ModelInterface
+{
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName();
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes();
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats();
+
+    /**
+     * Array of attributes where the key is the local name, and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap();
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters();
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters();
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array
+     */
+    public function listInvalidProperties();
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool
+     */
+    public function valid();
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool;
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool;
+}

+ 600 - 0
api-test/client/lib/Model/PaginationMeta.php

@@ -0,0 +1,600 @@
+<?php
+/**
+ * PaginationMeta
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * PaginationMeta Class Doc Comment
+ *
+ * @category Class
+ * @description v3 列表接口的分页信息,由 Laravel paginator 生成。 刻意不含 links / path:那些是 APP_URL 拼出的绝对地址,反代下会拼错,前端也用不到。 手工分页的接口会附加自己的字段(如 has_more / first_para / page_size)。
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PaginationMeta implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'PaginationMeta';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'current_page' => 'int',
+        'per_page' => 'int',
+        'total' => 'int',
+        'last_page' => 'int',
+        'from' => 'int',
+        'to' => 'int'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'current_page' => null,
+        'per_page' => null,
+        'total' => null,
+        'last_page' => null,
+        'from' => null,
+        'to' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'current_page' => false,
+        'per_page' => false,
+        'total' => false,
+        'last_page' => false,
+        'from' => true,
+        'to' => true
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'current_page' => 'current_page',
+        'per_page' => 'per_page',
+        'total' => 'total',
+        'last_page' => 'last_page',
+        'from' => 'from',
+        'to' => 'to'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'current_page' => 'setCurrentPage',
+        'per_page' => 'setPerPage',
+        'total' => 'setTotal',
+        'last_page' => 'setLastPage',
+        'from' => 'setFrom',
+        'to' => 'setTo'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'current_page' => 'getCurrentPage',
+        'per_page' => 'getPerPage',
+        'total' => 'getTotal',
+        'last_page' => 'getLastPage',
+        'from' => 'getFrom',
+        'to' => 'getTo'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('current_page', $data ?? [], null);
+        $this->setIfExists('per_page', $data ?? [], null);
+        $this->setIfExists('total', $data ?? [], null);
+        $this->setIfExists('last_page', $data ?? [], null);
+        $this->setIfExists('from', $data ?? [], null);
+        $this->setIfExists('to', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        if ($this->container['current_page'] === null) {
+            $invalidProperties[] = "'current_page' can't be null";
+        }
+        if ($this->container['total'] === null) {
+            $invalidProperties[] = "'total' can't be null";
+        }
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets current_page
+     *
+     * @return int
+     */
+    public function getCurrentPage()
+    {
+        return $this->container['current_page'];
+    }
+
+    /**
+     * Sets current_page
+     *
+     * @param int $current_page current_page
+     *
+     * @return self
+     */
+    public function setCurrentPage($current_page)
+    {
+        if (is_null($current_page)) {
+            throw new \InvalidArgumentException('non-nullable current_page cannot be null');
+        }
+        $this->container['current_page'] = $current_page;
+
+        return $this;
+    }
+
+    /**
+     * Gets per_page
+     *
+     * @return int|null
+     */
+    public function getPerPage()
+    {
+        return $this->container['per_page'];
+    }
+
+    /**
+     * Sets per_page
+     *
+     * @param int|null $per_page per_page
+     *
+     * @return self
+     */
+    public function setPerPage($per_page)
+    {
+        if (is_null($per_page)) {
+            throw new \InvalidArgumentException('non-nullable per_page cannot be null');
+        }
+        $this->container['per_page'] = $per_page;
+
+        return $this;
+    }
+
+    /**
+     * Gets total
+     *
+     * @return int
+     */
+    public function getTotal()
+    {
+        return $this->container['total'];
+    }
+
+    /**
+     * Sets total
+     *
+     * @param int $total total
+     *
+     * @return self
+     */
+    public function setTotal($total)
+    {
+        if (is_null($total)) {
+            throw new \InvalidArgumentException('non-nullable total cannot be null');
+        }
+        $this->container['total'] = $total;
+
+        return $this;
+    }
+
+    /**
+     * Gets last_page
+     *
+     * @return int|null
+     */
+    public function getLastPage()
+    {
+        return $this->container['last_page'];
+    }
+
+    /**
+     * Sets last_page
+     *
+     * @param int|null $last_page last_page
+     *
+     * @return self
+     */
+    public function setLastPage($last_page)
+    {
+        if (is_null($last_page)) {
+            throw new \InvalidArgumentException('non-nullable last_page cannot be null');
+        }
+        $this->container['last_page'] = $last_page;
+
+        return $this;
+    }
+
+    /**
+     * Gets from
+     *
+     * @return int|null
+     */
+    public function getFrom()
+    {
+        return $this->container['from'];
+    }
+
+    /**
+     * Sets from
+     *
+     * @param int|null $from from
+     *
+     * @return self
+     */
+    public function setFrom($from)
+    {
+        if (is_null($from)) {
+            array_push($this->openAPINullablesSetToNull, 'from');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('from', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['from'] = $from;
+
+        return $this;
+    }
+
+    /**
+     * Gets to
+     *
+     * @return int|null
+     */
+    public function getTo()
+    {
+        return $this->container['to'];
+    }
+
+    /**
+     * Sets to
+     *
+     * @param int|null $to to
+     *
+     * @return self
+     */
+    public function setTo($to)
+    {
+        if (is_null($to)) {
+            array_push($this->openAPINullablesSetToNull, 'to');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('to', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['to'] = $to;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 409 - 0
api-test/client/lib/Model/PostApiV3MeReactions200Response.php

@@ -0,0 +1,409 @@
+<?php
+/**
+ * PostApiV3MeReactions200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * PostApiV3MeReactions200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3MeReactions200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_me_reactions_200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInner'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInner|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3MeReactions200ResponseDataInner|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 602 - 0
api-test/client/lib/Model/PostApiV3MeReactionsRequest.php

@@ -0,0 +1,602 @@
+<?php
+/**
+ * PostApiV3MeReactionsRequest
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * PostApiV3MeReactionsRequest Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3MeReactionsRequest implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_me_reactions_request';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'type' => 'string',
+        'target_id' => 'string',
+        'target_type' => 'string',
+        'context' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'type' => null,
+        'target_id' => null,
+        'target_type' => null,
+        'context' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'type' => false,
+        'target_id' => false,
+        'target_type' => false,
+        'context' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'type' => 'type',
+        'target_id' => 'target_id',
+        'target_type' => 'target_type',
+        'context' => 'context'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'type' => 'setType',
+        'target_id' => 'setTargetId',
+        'target_type' => 'setTargetType',
+        'context' => 'setContext'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'type' => 'getType',
+        'target_id' => 'getTargetId',
+        'target_type' => 'getTargetType',
+        'context' => 'getContext'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+    public const TYPE_LIKE = 'like';
+    public const TYPE_DISLIKE = 'dislike';
+    public const TYPE_FAVORITE = 'favorite';
+    public const TYPE_WATCH = 'watch';
+    public const TYPE_BOOKMARK = 'bookmark';
+    public const TYPE_DOWNLOAD = 'download';
+    public const TARGET_TYPE_TASK = 'task';
+    public const TARGET_TYPE_COLLECTION = 'collection';
+    public const TARGET_TYPE_PROGRESS_CHAPTER = 'progress_chapter';
+    public const TARGET_TYPE_ARTICLE = 'article';
+    public const TARGET_TYPE_TERMS = 'terms';
+
+    /**
+     * Gets allowable values of the enum
+     *
+     * @return string[]
+     */
+    public function getTypeAllowableValues()
+    {
+        return [
+            self::TYPE_LIKE,
+            self::TYPE_DISLIKE,
+            self::TYPE_FAVORITE,
+            self::TYPE_WATCH,
+            self::TYPE_BOOKMARK,
+            self::TYPE_DOWNLOAD,
+        ];
+    }
+
+    /**
+     * Gets allowable values of the enum
+     *
+     * @return string[]
+     */
+    public function getTargetTypeAllowableValues()
+    {
+        return [
+            self::TARGET_TYPE_TASK,
+            self::TARGET_TYPE_COLLECTION,
+            self::TARGET_TYPE_PROGRESS_CHAPTER,
+            self::TARGET_TYPE_ARTICLE,
+            self::TARGET_TYPE_TERMS,
+        ];
+    }
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('type', $data ?? [], null);
+        $this->setIfExists('target_id', $data ?? [], null);
+        $this->setIfExists('target_type', $data ?? [], null);
+        $this->setIfExists('context', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        if ($this->container['type'] === null) {
+            $invalidProperties[] = "'type' can't be null";
+        }
+        $allowedValues = $this->getTypeAllowableValues();
+        if (!is_null($this->container['type']) && !in_array($this->container['type'], $allowedValues, true)) {
+            $invalidProperties[] = sprintf(
+                "invalid value '%s' for 'type', must be one of '%s'",
+                $this->container['type'],
+                implode("', '", $allowedValues)
+            );
+        }
+
+        if ($this->container['target_id'] === null) {
+            $invalidProperties[] = "'target_id' can't be null";
+        }
+        if ($this->container['target_type'] === null) {
+            $invalidProperties[] = "'target_type' can't be null";
+        }
+        $allowedValues = $this->getTargetTypeAllowableValues();
+        if (!is_null($this->container['target_type']) && !in_array($this->container['target_type'], $allowedValues, true)) {
+            $invalidProperties[] = sprintf(
+                "invalid value '%s' for 'target_type', must be one of '%s'",
+                $this->container['target_type'],
+                implode("', '", $allowedValues)
+            );
+        }
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets type
+     *
+     * @return string
+     */
+    public function getType()
+    {
+        return $this->container['type'];
+    }
+
+    /**
+     * Sets type
+     *
+     * @param string $type 操作类型
+     *
+     * @return self
+     */
+    public function setType($type)
+    {
+        if (is_null($type)) {
+            throw new \InvalidArgumentException('non-nullable type cannot be null');
+        }
+        $allowedValues = $this->getTypeAllowableValues();
+        if (!in_array($type, $allowedValues, true)) {
+            throw new \InvalidArgumentException(
+                sprintf(
+                    "Invalid value '%s' for 'type', must be one of '%s'",
+                    $type,
+                    implode("', '", $allowedValues)
+                )
+            );
+        }
+        $this->container['type'] = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets target_id
+     *
+     * @return string
+     */
+    public function getTargetId()
+    {
+        return $this->container['target_id'];
+    }
+
+    /**
+     * Sets target_id
+     *
+     * @param string $target_id target 的 uuid
+     *
+     * @return self
+     */
+    public function setTargetId($target_id)
+    {
+        if (is_null($target_id)) {
+            throw new \InvalidArgumentException('non-nullable target_id cannot be null');
+        }
+        $this->container['target_id'] = $target_id;
+
+        return $this;
+    }
+
+    /**
+     * Gets target_type
+     *
+     * @return string
+     */
+    public function getTargetType()
+    {
+        return $this->container['target_type'];
+    }
+
+    /**
+     * Sets target_type
+     *
+     * @param string $target_type target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语)
+     *
+     * @return self
+     */
+    public function setTargetType($target_type)
+    {
+        if (is_null($target_type)) {
+            throw new \InvalidArgumentException('non-nullable target_type cannot be null');
+        }
+        $allowedValues = $this->getTargetTypeAllowableValues();
+        if (!in_array($target_type, $allowedValues, true)) {
+            throw new \InvalidArgumentException(
+                sprintf(
+                    "Invalid value '%s' for 'target_type', must be one of '%s'",
+                    $target_type,
+                    implode("', '", $allowedValues)
+                )
+            );
+        }
+        $this->container['target_type'] = $target_type;
+
+        return $this;
+    }
+
+    /**
+     * Gets context
+     *
+     * @return string|null
+     */
+    public function getContext()
+    {
+        return $this->container['context'];
+    }
+
+    /**
+     * Sets context
+     *
+     * @param string|null $context 附加上下文,最长 128 字符
+     *
+     * @return self
+     */
+    public function setContext($context)
+    {
+        if (is_null($context)) {
+            throw new \InvalidArgumentException('non-nullable context cannot be null');
+        }
+        $this->container['context'] = $context;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 858 - 0
api-test/client/lib/Model/PostApiV3SearchRequest.php

@@ -0,0 +1,858 @@
+<?php
+/**
+ * PostApiV3SearchRequest
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * PostApiV3SearchRequest Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class PostApiV3SearchRequest implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'post_api_v3_search_request';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'q' => 'string',
+        'search_mode' => 'string',
+        'resource_type' => 'string',
+        'granularity' => 'string',
+        'language' => 'string',
+        'category' => 'string',
+        'tags' => 'string',
+        'page_refs' => 'string[]',
+        'related_id' => 'string[]',
+        'author' => 'string',
+        'channel' => 'string',
+        'page' => 'int',
+        'page_size' => 'int'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'q' => null,
+        'search_mode' => null,
+        'resource_type' => null,
+        'granularity' => null,
+        'language' => null,
+        'category' => null,
+        'tags' => null,
+        'page_refs' => null,
+        'related_id' => null,
+        'author' => null,
+        'channel' => null,
+        'page' => null,
+        'page_size' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'q' => false,
+        'search_mode' => false,
+        'resource_type' => false,
+        'granularity' => false,
+        'language' => false,
+        'category' => false,
+        'tags' => false,
+        'page_refs' => false,
+        'related_id' => false,
+        'author' => false,
+        'channel' => false,
+        'page' => false,
+        'page_size' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'q' => 'q',
+        'search_mode' => 'search_mode',
+        'resource_type' => 'resource_type',
+        'granularity' => 'granularity',
+        'language' => 'language',
+        'category' => 'category',
+        'tags' => 'tags',
+        'page_refs' => 'page_refs',
+        'related_id' => 'related_id',
+        'author' => 'author',
+        'channel' => 'channel',
+        'page' => 'page',
+        'page_size' => 'page_size'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'q' => 'setQ',
+        'search_mode' => 'setSearchMode',
+        'resource_type' => 'setResourceType',
+        'granularity' => 'setGranularity',
+        'language' => 'setLanguage',
+        'category' => 'setCategory',
+        'tags' => 'setTags',
+        'page_refs' => 'setPageRefs',
+        'related_id' => 'setRelatedId',
+        'author' => 'setAuthor',
+        'channel' => 'setChannel',
+        'page' => 'setPage',
+        'page_size' => 'setPageSize'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'q' => 'getQ',
+        'search_mode' => 'getSearchMode',
+        'resource_type' => 'getResourceType',
+        'granularity' => 'getGranularity',
+        'language' => 'getLanguage',
+        'category' => 'getCategory',
+        'tags' => 'getTags',
+        'page_refs' => 'getPageRefs',
+        'related_id' => 'getRelatedId',
+        'author' => 'getAuthor',
+        'channel' => 'getChannel',
+        'page' => 'getPage',
+        'page_size' => 'getPageSize'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+    public const SEARCH_MODE_FUZZY = 'fuzzy';
+    public const SEARCH_MODE_EXACT = 'exact';
+    public const SEARCH_MODE_SEMANTIC = 'semantic';
+    public const SEARCH_MODE_HYBRID = 'hybrid';
+
+    /**
+     * Gets allowable values of the enum
+     *
+     * @return string[]
+     */
+    public function getSearchModeAllowableValues()
+    {
+        return [
+            self::SEARCH_MODE_FUZZY,
+            self::SEARCH_MODE_EXACT,
+            self::SEARCH_MODE_SEMANTIC,
+            self::SEARCH_MODE_HYBRID,
+        ];
+    }
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('q', $data ?? [], null);
+        $this->setIfExists('search_mode', $data ?? [], 'fuzzy');
+        $this->setIfExists('resource_type', $data ?? [], null);
+        $this->setIfExists('granularity', $data ?? [], null);
+        $this->setIfExists('language', $data ?? [], null);
+        $this->setIfExists('category', $data ?? [], null);
+        $this->setIfExists('tags', $data ?? [], null);
+        $this->setIfExists('page_refs', $data ?? [], null);
+        $this->setIfExists('related_id', $data ?? [], null);
+        $this->setIfExists('author', $data ?? [], null);
+        $this->setIfExists('channel', $data ?? [], null);
+        $this->setIfExists('page', $data ?? [], 1);
+        $this->setIfExists('page_size', $data ?? [], 20);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        if ($this->container['q'] === null) {
+            $invalidProperties[] = "'q' can't be null";
+        }
+        $allowedValues = $this->getSearchModeAllowableValues();
+        if (!is_null($this->container['search_mode']) && !in_array($this->container['search_mode'], $allowedValues, true)) {
+            $invalidProperties[] = sprintf(
+                "invalid value '%s' for 'search_mode', must be one of '%s'",
+                $this->container['search_mode'],
+                implode("', '", $allowedValues)
+            );
+        }
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets q
+     *
+     * @return string
+     */
+    public function getQ()
+    {
+        return $this->container['q'];
+    }
+
+    /**
+     * Sets q
+     *
+     * @param string $q 搜索关键词
+     *
+     * @return self
+     */
+    public function setQ($q)
+    {
+        if (is_null($q)) {
+            throw new \InvalidArgumentException('non-nullable q cannot be null');
+        }
+        $this->container['q'] = $q;
+
+        return $this;
+    }
+
+    /**
+     * Gets search_mode
+     *
+     * @return string|null
+     */
+    public function getSearchMode()
+    {
+        return $this->container['search_mode'];
+    }
+
+    /**
+     * Sets search_mode
+     *
+     * @param string|null $search_mode 检索模式
+     *
+     * @return self
+     */
+    public function setSearchMode($search_mode)
+    {
+        if (is_null($search_mode)) {
+            throw new \InvalidArgumentException('non-nullable search_mode cannot be null');
+        }
+        $allowedValues = $this->getSearchModeAllowableValues();
+        if (!in_array($search_mode, $allowedValues, true)) {
+            throw new \InvalidArgumentException(
+                sprintf(
+                    "Invalid value '%s' for 'search_mode', must be one of '%s'",
+                    $search_mode,
+                    implode("', '", $allowedValues)
+                )
+            );
+        }
+        $this->container['search_mode'] = $search_mode;
+
+        return $this;
+    }
+
+    /**
+     * Gets resource_type
+     *
+     * @return string|null
+     */
+    public function getResourceType()
+    {
+        return $this->container['resource_type'];
+    }
+
+    /**
+     * Sets resource_type
+     *
+     * @param string|null $resource_type 资源类型
+     *
+     * @return self
+     */
+    public function setResourceType($resource_type)
+    {
+        if (is_null($resource_type)) {
+            throw new \InvalidArgumentException('non-nullable resource_type cannot be null');
+        }
+        $this->container['resource_type'] = $resource_type;
+
+        return $this;
+    }
+
+    /**
+     * Gets granularity
+     *
+     * @return string|null
+     */
+    public function getGranularity()
+    {
+        return $this->container['granularity'];
+    }
+
+    /**
+     * Sets granularity
+     *
+     * @param string|null $granularity 文档颗粒度
+     *
+     * @return self
+     */
+    public function setGranularity($granularity)
+    {
+        if (is_null($granularity)) {
+            throw new \InvalidArgumentException('non-nullable granularity cannot be null');
+        }
+        $this->container['granularity'] = $granularity;
+
+        return $this;
+    }
+
+    /**
+     * Gets language
+     *
+     * @return string|null
+     */
+    public function getLanguage()
+    {
+        return $this->container['language'];
+    }
+
+    /**
+     * Sets language
+     *
+     * @param string|null $language 语言
+     *
+     * @return self
+     */
+    public function setLanguage($language)
+    {
+        if (is_null($language)) {
+            throw new \InvalidArgumentException('non-nullable language cannot be null');
+        }
+        $this->container['language'] = $language;
+
+        return $this;
+    }
+
+    /**
+     * Gets category
+     *
+     * @return string|null
+     */
+    public function getCategory()
+    {
+        return $this->container['category'];
+    }
+
+    /**
+     * Sets category
+     *
+     * @param string|null $category 文档分类,逗号分隔
+     *
+     * @return self
+     */
+    public function setCategory($category)
+    {
+        if (is_null($category)) {
+            throw new \InvalidArgumentException('non-nullable category cannot be null');
+        }
+        $this->container['category'] = $category;
+
+        return $this;
+    }
+
+    /**
+     * Gets tags
+     *
+     * @return string|null
+     */
+    public function getTags()
+    {
+        return $this->container['tags'];
+    }
+
+    /**
+     * Sets tags
+     *
+     * @param string|null $tags 标签,逗号分隔
+     *
+     * @return self
+     */
+    public function setTags($tags)
+    {
+        if (is_null($tags)) {
+            throw new \InvalidArgumentException('non-nullable tags cannot be null');
+        }
+        $this->container['tags'] = $tags;
+
+        return $this;
+    }
+
+    /**
+     * Gets page_refs
+     *
+     * @return string[]|null
+     */
+    public function getPageRefs()
+    {
+        return $this->container['page_refs'];
+    }
+
+    /**
+     * Sets page_refs
+     *
+     * @param string[]|null $page_refs 页码标记
+     *
+     * @return self
+     */
+    public function setPageRefs($page_refs)
+    {
+        if (is_null($page_refs)) {
+            throw new \InvalidArgumentException('non-nullable page_refs cannot be null');
+        }
+        $this->container['page_refs'] = $page_refs;
+
+        return $this;
+    }
+
+    /**
+     * Gets related_id
+     *
+     * @return string[]|null
+     */
+    public function getRelatedId()
+    {
+        return $this->container['related_id'];
+    }
+
+    /**
+     * Sets related_id
+     *
+     * @param string[]|null $related_id 关联 id
+     *
+     * @return self
+     */
+    public function setRelatedId($related_id)
+    {
+        if (is_null($related_id)) {
+            throw new \InvalidArgumentException('non-nullable related_id cannot be null');
+        }
+        $this->container['related_id'] = $related_id;
+
+        return $this;
+    }
+
+    /**
+     * Gets author
+     *
+     * @return string|null
+     */
+    public function getAuthor()
+    {
+        return $this->container['author'];
+    }
+
+    /**
+     * Sets author
+     *
+     * @param string|null $author 作者或译者
+     *
+     * @return self
+     */
+    public function setAuthor($author)
+    {
+        if (is_null($author)) {
+            throw new \InvalidArgumentException('non-nullable author cannot be null');
+        }
+        $this->container['author'] = $author;
+
+        return $this;
+    }
+
+    /**
+     * Gets channel
+     *
+     * @return string|null
+     */
+    public function getChannel()
+    {
+        return $this->container['channel'];
+    }
+
+    /**
+     * Sets channel
+     *
+     * @param string|null $channel 来源 channel
+     *
+     * @return self
+     */
+    public function setChannel($channel)
+    {
+        if (is_null($channel)) {
+            throw new \InvalidArgumentException('non-nullable channel cannot be null');
+        }
+        $this->container['channel'] = $channel;
+
+        return $this;
+    }
+
+    /**
+     * Gets page
+     *
+     * @return int|null
+     */
+    public function getPage()
+    {
+        return $this->container['page'];
+    }
+
+    /**
+     * Sets page
+     *
+     * @param int|null $page 页码
+     *
+     * @return self
+     */
+    public function setPage($page)
+    {
+        if (is_null($page)) {
+            throw new \InvalidArgumentException('non-nullable page cannot be null');
+        }
+        $this->container['page'] = $page;
+
+        return $this;
+    }
+
+    /**
+     * Gets page_size
+     *
+     * @return int|null
+     */
+    public function getPageSize()
+    {
+        return $this->container['page_size'];
+    }
+
+    /**
+     * Sets page_size
+     *
+     * @param int|null $page_size 每页数量,上限 100
+     *
+     * @return self
+     */
+    public function setPageSize($page_size)
+    {
+        if (is_null($page_size)) {
+            throw new \InvalidArgumentException('non-nullable page_size cannot be null');
+        }
+        $this->container['page_size'] = $page_size;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 589 - 0
api-test/client/lib/Model/ProblemDetails.php

@@ -0,0 +1,589 @@
+<?php
+/**
+ * ProblemDetails
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * ProblemDetails Class Doc Comment
+ *
+ * @category Class
+ * @description RFC 9457 Problem Details,v3 端点的错误响应体。 由 bootstrap/app.php 的异常处理器统一渲染,控制器不自己拼。 media type 是 application/problem+json。
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class ProblemDetails implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'ProblemDetails';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'type' => 'string',
+        'title' => 'string',
+        'status' => 'int',
+        'detail' => 'string',
+        'instance' => 'string',
+        'errors' => 'array<string,string[]>'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'type' => null,
+        'title' => null,
+        'status' => null,
+        'detail' => null,
+        'instance' => null,
+        'errors' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'type' => false,
+        'title' => false,
+        'status' => false,
+        'detail' => false,
+        'instance' => false,
+        'errors' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'type' => 'type',
+        'title' => 'title',
+        'status' => 'status',
+        'detail' => 'detail',
+        'instance' => 'instance',
+        'errors' => 'errors'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'type' => 'setType',
+        'title' => 'setTitle',
+        'status' => 'setStatus',
+        'detail' => 'setDetail',
+        'instance' => 'setInstance',
+        'errors' => 'setErrors'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'type' => 'getType',
+        'title' => 'getTitle',
+        'status' => 'getStatus',
+        'detail' => 'getDetail',
+        'instance' => 'getInstance',
+        'errors' => 'getErrors'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('type', $data ?? [], null);
+        $this->setIfExists('title', $data ?? [], null);
+        $this->setIfExists('status', $data ?? [], null);
+        $this->setIfExists('detail', $data ?? [], null);
+        $this->setIfExists('instance', $data ?? [], null);
+        $this->setIfExists('errors', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        if ($this->container['type'] === null) {
+            $invalidProperties[] = "'type' can't be null";
+        }
+        if ($this->container['title'] === null) {
+            $invalidProperties[] = "'title' can't be null";
+        }
+        if ($this->container['status'] === null) {
+            $invalidProperties[] = "'status' can't be null";
+        }
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets type
+     *
+     * @return string
+     */
+    public function getType()
+    {
+        return $this->container['type'];
+    }
+
+    /**
+     * Sets type
+     *
+     * @param string $type 问题类型标识,形如 urn:problem:not-found
+     *
+     * @return self
+     */
+    public function setType($type)
+    {
+        if (is_null($type)) {
+            throw new \InvalidArgumentException('non-nullable type cannot be null');
+        }
+        $this->container['type'] = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets title
+     *
+     * @return string
+     */
+    public function getTitle()
+    {
+        return $this->container['title'];
+    }
+
+    /**
+     * Sets title
+     *
+     * @param string $title 稳定的英文摘要,同类问题恒定,供机器识别
+     *
+     * @return self
+     */
+    public function setTitle($title)
+    {
+        if (is_null($title)) {
+            throw new \InvalidArgumentException('non-nullable title cannot be null');
+        }
+        $this->container['title'] = $title;
+
+        return $this;
+    }
+
+    /**
+     * Gets status
+     *
+     * @return int
+     */
+    public function getStatus()
+    {
+        return $this->container['status'];
+    }
+
+    /**
+     * Sets status
+     *
+     * @param int $status status
+     *
+     * @return self
+     */
+    public function setStatus($status)
+    {
+        if (is_null($status)) {
+            throw new \InvalidArgumentException('non-nullable status cannot be null');
+        }
+        $this->container['status'] = $status;
+
+        return $this;
+    }
+
+    /**
+     * Gets detail
+     *
+     * @return string|null
+     */
+    public function getDetail()
+    {
+        return $this->container['detail'];
+    }
+
+    /**
+     * Sets detail
+     *
+     * @param string|null $detail 面向人的文案,已本地化;5xx 在生产环境会省略以免泄露内部信息
+     *
+     * @return self
+     */
+    public function setDetail($detail)
+    {
+        if (is_null($detail)) {
+            throw new \InvalidArgumentException('non-nullable detail cannot be null');
+        }
+        $this->container['detail'] = $detail;
+
+        return $this;
+    }
+
+    /**
+     * Gets instance
+     *
+     * @return string|null
+     */
+    public function getInstance()
+    {
+        return $this->container['instance'];
+    }
+
+    /**
+     * Sets instance
+     *
+     * @param string|null $instance 出问题的请求 URI
+     *
+     * @return self
+     */
+    public function setInstance($instance)
+    {
+        if (is_null($instance)) {
+            throw new \InvalidArgumentException('non-nullable instance cannot be null');
+        }
+        $this->container['instance'] = $instance;
+
+        return $this;
+    }
+
+    /**
+     * Gets errors
+     *
+     * @return array<string,string[]>|null
+     */
+    public function getErrors()
+    {
+        return $this->container['errors'];
+    }
+
+    /**
+     * Sets errors
+     *
+     * @param array<string,string[]>|null $errors 字段级校验错误,仅 422 时出现
+     *
+     * @return self
+     */
+    public function setErrors($errors)
+    {
+        if (is_null($errors)) {
+            throw new \InvalidArgumentException('non-nullable errors cannot be null');
+        }
+        $this->container['errors'] = $errors;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 603 - 0
api-test/client/lib/ObjectSerializer.php

@@ -0,0 +1,603 @@
+<?php
+/**
+ * ObjectSerializer
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.9.28
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi;
+
+use GuzzleHttp\Psr7\Utils;
+use WikipaliApi\Model\ModelInterface;
+
+/**
+ * ObjectSerializer Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class ObjectSerializer
+{
+    /** @var string */
+    private static $dateTimeFormat = \DateTime::ATOM;
+
+    /**
+     * Change the date format
+     *
+     * @param string $format   the new date format to use
+     */
+    public static function setDateTimeFormat($format)
+    {
+        self::$dateTimeFormat = $format;
+    }
+
+    /**
+     * Serialize data
+     *
+     * @param mixed  $data   the data to serialize
+     * @param string|null $type   the OpenAPIToolsType of the data
+     * @param string|null $format the format of the OpenAPITools type of the data
+     *
+     * @return scalar|object|array|null serialized form of $data
+     */
+    public static function sanitizeForSerialization($data, $type = null, $format = null)
+    {
+        if (is_scalar($data) || null === $data) {
+            return $data;
+        }
+
+        if ($data instanceof \DateTime) {
+            return ($format === 'date') ? $data->format('Y-m-d') : $data->format(self::$dateTimeFormat);
+        }
+
+        if (is_array($data)) {
+            foreach ($data as $property => $value) {
+                $data[$property] = self::sanitizeForSerialization($value);
+            }
+            return $data;
+        }
+
+        if (is_object($data)) {
+            $values = [];
+            if ($data instanceof ModelInterface) {
+                $formats = $data::openAPIFormats();
+                foreach ($data::openAPITypes() as $property => $openAPIType) {
+                    $getter = $data::getters()[$property];
+                    $value = $data->$getter();
+                    if ($value !== null && !in_array($openAPIType, ['\DateTime', '\SplFileObject', 'array', 'bool', 'boolean', 'byte', 'float', 'int', 'integer', 'mixed', 'number', 'object', 'string', 'void'], true)) {
+                        $callable = [$openAPIType, 'getAllowableEnumValues'];
+                        if (is_callable($callable)) {
+                            /** array $callable */
+                            $allowedEnumTypes = $callable();
+                            if (!in_array($value, $allowedEnumTypes, true)) {
+                                $imploded = implode("', '", $allowedEnumTypes);
+                                throw new \InvalidArgumentException("Invalid value for enum '$openAPIType', must be one of: '$imploded'");
+                            }
+                        }
+                    }
+                    if (($data::isNullable($property) && $data->isNullableSetToNull($property)) || $value !== null) {
+                        $values[$data::attributeMap()[$property]] = self::sanitizeForSerialization($value, $openAPIType, $formats[$property]);
+                    }
+                }
+            } else {
+                foreach ($data as $property => $value) {
+                    $values[$property] = self::sanitizeForSerialization($value);
+                }
+            }
+            return (object) $values;
+        } else {
+            return (string)$data;
+        }
+    }
+
+    /**
+     * Sanitize filename by removing path.
+     * e.g. ../../sun.gif becomes sun.gif
+     *
+     * @param string $filename filename to be sanitized
+     *
+     * @return string the sanitized filename
+     */
+    public static function sanitizeFilename($filename)
+    {
+        if (preg_match("/.*[\/\\\\](.*)$/", $filename, $match)) {
+            return $match[1];
+        } else {
+            return $filename;
+        }
+    }
+
+    /**
+     * Shorter timestamp microseconds to 6 digits length.
+     *
+     * @param string $timestamp Original timestamp
+     *
+     * @return string the shorten timestamp
+     */
+    public static function sanitizeTimestamp($timestamp)
+    {
+        if (!is_string($timestamp)) {
+            return $timestamp;
+        }
+
+        return preg_replace('/(:\d{2}.\d{6})\d*/', '$1', $timestamp);
+    }
+
+    /**
+     * Take value and turn it into a string suitable for inclusion in
+     * the path, by url-encoding.
+     *
+     * @param string $value a string which will be part of the path
+     *
+     * @return string the serialized object
+     */
+    public static function toPathValue($value)
+    {
+        return rawurlencode(self::toString($value));
+    }
+
+    /**
+     * Checks if a value is empty, based on its OpenAPI type.
+     *
+     * @param mixed  $value
+     * @param string $openApiType
+     *
+     * @return bool true if $value is empty
+     */
+    private static function isEmptyValue($value, string $openApiType): bool
+    {
+        // If empty() returns false, it is not empty regardless of its type.
+        if (!empty($value)) {
+            return false;
+        }
+
+        // Null is always empty, as we cannot send a real "null" value in a query parameter.
+        if ($value === null) {
+            return true;
+        }
+
+        switch ($openApiType) {
+            // For numeric values, false and '' are considered empty.
+            // This comparison is safe for floating point values, since the previous call to empty() will
+            // filter out values that don't match 0.
+            case 'int':
+            case 'integer':
+                return $value !== 0;
+
+            case 'number':
+            case 'float':
+                return $value !== 0 && $value !== 0.0;
+
+            // For boolean values, '' is considered empty
+            case 'bool':
+            case 'boolean':
+                return !in_array($value, [false, 0], true);
+
+            // For string values, '' is considered empty.
+            case 'string':
+                return $value === '';
+
+            // For all the other types, any value at this point can be considered empty.
+            default:
+                return true;
+        }
+    }
+
+    /**
+     * Take query parameter properties and turn it into an array suitable for
+     * native http_build_query or GuzzleHttp\Psr7\Query::build.
+     *
+     * @param mixed  $value       Parameter value
+     * @param string $paramName   Parameter name
+     * @param string $openApiType OpenAPIType eg. array or object
+     * @param string $style       Parameter serialization style
+     * @param bool   $explode     Parameter explode option
+     * @param bool   $required    Whether query param is required or not
+     *
+     * @return array
+     */
+    public static function toQueryValue(
+        $value,
+        string $paramName,
+        string $openApiType = 'string',
+        string $style = 'form',
+        bool $explode = true,
+        bool $required = true
+    ): array {
+
+        // Check if we should omit this parameter from the query. This should only happen when:
+        //  - Parameter is NOT required; AND
+        //  - its value is set to a value that is equivalent to "empty", depending on its OpenAPI type. For
+        //    example, 0 as "int" or "boolean" is NOT an empty value.
+        if (self::isEmptyValue($value, $openApiType)) {
+            if ($required) {
+                return ["{$paramName}" => ''];
+            } else {
+                return [];
+            }
+        }
+
+        // Handle DateTime objects in query
+        if ($openApiType === "\\DateTime" && $value instanceof \DateTime) {
+            return ["{$paramName}" => $value->format(self::$dateTimeFormat)];
+        }
+
+        $query = [];
+        $value = (in_array($openApiType, ['object', 'array'], true)) ? (array)$value : $value;
+
+        // since \GuzzleHttp\Psr7\Query::build fails with nested arrays
+        // need to flatten array first
+        $flattenArray = function ($arr, $name, &$result = []) use (&$flattenArray, $style, $explode) {
+            if (!is_array($arr)) {
+                return $arr;
+            }
+
+            foreach ($arr as $k => $v) {
+                $prop = ($style === 'deepObject') ? "{$name}[{$k}]" : $k;
+
+                if (is_array($v)) {
+                    $flattenArray($v, $prop, $result);
+                } else {
+                    if ($style !== 'deepObject' && !$explode) {
+                        // push key itself
+                        $result[] = $prop;
+                    }
+                    $result[$prop] = $v;
+                }
+            }
+            return $result;
+        };
+
+        $value = $flattenArray($value, $paramName);
+
+        // https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#style-values
+        if ($openApiType === 'array' && $style === 'deepObject' && $explode) {
+            return $value;
+        }
+
+        if ($openApiType === 'object' && ($style === 'deepObject' || $explode)) {
+            return $value;
+        }
+
+        if ('boolean' === $openApiType && is_bool($value)) {
+            $value = self::convertBoolToQueryStringFormat($value);
+        }
+
+        // handle style in serializeCollection
+        $query[$paramName] = ($explode) ? $value : self::serializeCollection((array)$value, $style);
+
+        return $query;
+    }
+
+    /**
+     * Convert boolean value to format for query string.
+     *
+     * @param bool $value Boolean value
+     *
+     * @return int|string Boolean value in format
+     */
+    public static function convertBoolToQueryStringFormat(bool $value)
+    {
+        if (Configuration::BOOLEAN_FORMAT_STRING == Configuration::getDefaultConfiguration()->getBooleanFormatForQueryString()) {
+            return $value ? 'true' : 'false';
+        }
+
+        return (int) $value;
+    }
+
+    /**
+     * Take value and turn it into a string suitable for inclusion in
+     * the header. If it's a string, pass through unchanged
+     * If it's a datetime object, format it in ISO8601
+     *
+     * @param string $value a string which will be part of the header
+     *
+     * @return string the header string
+     */
+    public static function toHeaderValue($value)
+    {
+        $callable = [$value, 'toHeaderValue'];
+        if (is_callable($callable)) {
+            return $callable();
+        }
+
+        return self::toString($value);
+    }
+
+    /**
+     * Take value and turn it into a string suitable for inclusion in
+     * the parameter. If it's a string, pass through unchanged
+     * If it's a datetime object, format it in ISO8601
+     * If it's a boolean, convert it to "true" or "false".
+     *
+     * @param float|int|bool|\DateTime $value the value of the parameter
+     *
+     * @return string the header string
+     */
+    public static function toString($value)
+    {
+        if ($value instanceof \DateTime) { // datetime in ISO8601 format
+            return $value->format(self::$dateTimeFormat);
+        } elseif (is_bool($value)) {
+            return $value ? 'true' : 'false';
+        } else {
+            return (string) $value;
+        }
+    }
+
+    /**
+     * Serialize an array to a string.
+     *
+     * @param array  $collection                 collection to serialize to a string
+     * @param string $style                      the format use for serialization (csv,
+     * ssv, tsv, pipes, multi)
+     * @param bool   $allowCollectionFormatMulti allow collection format to be a multidimensional array
+     *
+     * @return string
+     */
+    public static function serializeCollection(array $collection, $style, $allowCollectionFormatMulti = false)
+    {
+        if ($allowCollectionFormatMulti && ('multi' === $style)) {
+            // http_build_query() almost does the job for us. We just
+            // need to fix the result of multidimensional arrays.
+            return preg_replace('/%5B[0-9]+%5D=/', '=', http_build_query($collection, '', '&'));
+        }
+        switch ($style) {
+            case 'pipeDelimited':
+            case 'pipes':
+                return implode('|', $collection);
+
+            case 'tsv':
+                return implode("\t", $collection);
+
+            case 'spaceDelimited':
+            case 'ssv':
+                return implode(' ', $collection);
+
+            case 'simple':
+            case 'csv':
+                // Deliberate fall through. CSV is default format.
+            default:
+                return implode(',', $collection);
+        }
+    }
+
+    /**
+     * Deserialize a JSON string into an object
+     *
+     * @param mixed    $data          object or primitive to be deserialized
+     * @param string   $class         class name is passed as a string
+     * @param string[]|null $httpHeaders   HTTP headers
+     *
+     * @return object|array|null a single or an array of $class instances
+     */
+    public static function deserialize($data, $class, $httpHeaders = null)
+    {
+        if (null === $data) {
+            return null;
+        }
+
+        if (strcasecmp(substr($class, -2), '[]') === 0) {
+            $data = is_string($data) ? json_decode($data) : $data;
+
+            if (!is_array($data)) {
+                throw new \InvalidArgumentException("Invalid array '$class'");
+            }
+
+            $subClass = substr($class, 0, -2);
+            $values = [];
+            foreach ($data as $key => $value) {
+                $values[] = self::deserialize($value, $subClass, null);
+            }
+            return $values;
+        }
+
+        if (preg_match('/^(array<|map\[)/', $class)) { // for associative array e.g. array<string,int>
+            $data = is_string($data) ? json_decode($data) : $data;
+            settype($data, 'array');
+            $inner = substr($class, 4, -1);
+            $deserialized = [];
+            if (strrpos($inner, ",") !== false) {
+                $subClass_array = explode(',', $inner, 2);
+                $subClass = $subClass_array[1];
+                foreach ($data as $key => $value) {
+                    $deserialized[$key] = self::deserialize($value, $subClass, null);
+                }
+            }
+            return $deserialized;
+        }
+
+        if ($class === 'object') {
+            settype($data, 'array');
+            return $data;
+        } elseif ($class === 'mixed') {
+            settype($data, gettype($data));
+            return $data;
+        }
+
+        if ($class === '\DateTime') {
+            // Some APIs return an invalid, empty string as a
+            // date-time property. DateTime::__construct() will return
+            // the current time for empty input which is probably not
+            // what is meant. The invalid empty string is probably to
+            // be interpreted as a missing field/value. Let's handle
+            // this graceful.
+            if (!empty($data)) {
+                try {
+                    return new \DateTime($data);
+                } catch (\Exception $exception) {
+                    // Some APIs return a date-time with too high nanosecond
+                    // precision for php's DateTime to handle.
+                    // With provided regexp 6 digits of microseconds saved
+                    return new \DateTime(self::sanitizeTimestamp($data));
+                }
+            } else {
+                return null;
+            }
+        }
+
+        if ($class === '\SplFileObject') {
+            $data = Utils::streamFor($data);
+
+            /** @var \Psr\Http\Message\StreamInterface $data */
+
+            // determine file name
+            if (
+                is_array($httpHeaders)
+                && array_key_exists('Content-Disposition', $httpHeaders)
+                && preg_match('/inline; filename=[\'"]?([^\'"\s]+)[\'"]?$/i', $httpHeaders['Content-Disposition'], $match)
+            ) {
+                $filename = Configuration::getDefaultConfiguration()->getTempFolderPath() . DIRECTORY_SEPARATOR . self::sanitizeFilename($match[1]);
+            } else {
+                $filename = tempnam(Configuration::getDefaultConfiguration()->getTempFolderPath(), '');
+            }
+
+            $file = fopen($filename, 'w');
+            while ($chunk = $data->read(200)) {
+                fwrite($file, $chunk);
+            }
+            fclose($file);
+
+            return new \SplFileObject($filename, 'r');
+        }
+
+        /** @psalm-suppress ParadoxicalCondition */
+        if (in_array($class, ['\DateTime', '\SplFileObject', 'array', 'bool', 'boolean', 'byte', 'float', 'int', 'integer', 'mixed', 'number', 'object', 'string', 'void'], true)) {
+            settype($data, $class);
+            return $data;
+        }
+
+
+        if (method_exists($class, 'getAllowableEnumValues')) {
+            if (!in_array($data, $class::getAllowableEnumValues(), true)) {
+                $imploded = implode("', '", $class::getAllowableEnumValues());
+                throw new \InvalidArgumentException("Invalid value for enum '$class', must be one of: '$imploded'");
+            }
+            return $data;
+        } else {
+            $data = is_string($data) ? json_decode($data) : $data;
+
+            if (is_array($data)) {
+                $data = (object) $data;
+            }
+
+            // If a discriminator is defined and points to a valid subclass, use it.
+            $discriminator = $class::DISCRIMINATOR;
+            if (!empty($discriminator) && isset($data->{$discriminator}) && is_string($data->{$discriminator})) {
+                $subclass = '\WikipaliApi\Model\\' . $data->{$discriminator};
+                if (is_subclass_of($subclass, $class)) {
+                    $class = $subclass;
+                }
+            }
+
+            /** @var ModelInterface $instance */
+            $instance = new $class();
+            foreach ($instance::openAPITypes() as $property => $type) {
+                $propertySetter = $instance::setters()[$property];
+
+                if (!isset($propertySetter)) {
+                    continue;
+                }
+
+                if (!isset($data->{$instance::attributeMap()[$property]})) {
+                    if ($instance::isNullable($property)) {
+                        $instance->$propertySetter(null);
+                    }
+
+                    continue;
+                }
+
+                if (isset($data->{$instance::attributeMap()[$property]})) {
+                    $propertyValue = $data->{$instance::attributeMap()[$property]};
+                    $instance->$propertySetter(self::deserialize($propertyValue, $type, null));
+                }
+            }
+            return $instance;
+        }
+    }
+
+    /**
+     * Build a query string from an array of key value pairs.
+     *
+     * This function can use the return value of `parse()` to build a query
+     * string. This function does not modify the provided keys when an array is
+     * encountered (like `http_build_query()` would).
+     *
+     * The function is copied from https://github.com/guzzle/psr7/blob/a243f80a1ca7fe8ceed4deee17f12c1930efe662/src/Query.php#L59-L112
+     * with a modification which is described in https://github.com/guzzle/psr7/pull/603
+     *
+     * @param array     $params              Query string parameters.
+     * @param int|false $encoding            Set to false to not encode, PHP_QUERY_RFC3986
+     *                                       to encode using RFC3986, or PHP_QUERY_RFC1738
+     *                                       to encode using RFC1738.
+     */
+    public static function buildQuery(array $params, $encoding = PHP_QUERY_RFC3986): string
+    {
+        if (!$params) {
+            return '';
+        }
+
+        if ($encoding === false) {
+            $encoder = function (string $str): string {
+                return $str;
+            };
+        } elseif ($encoding === PHP_QUERY_RFC3986) {
+            $encoder = 'rawurlencode';
+        } elseif ($encoding === PHP_QUERY_RFC1738) {
+            $encoder = 'urlencode';
+        } else {
+            throw new \InvalidArgumentException('Invalid type');
+        }
+
+        $castBool = Configuration::BOOLEAN_FORMAT_INT == Configuration::getDefaultConfiguration()->getBooleanFormatForQueryString()
+            ? function ($v) { return (int) $v; }
+            : function ($v) { return $v ? 'true' : 'false'; };
+
+        $qs = '';
+        foreach ($params as $k => $v) {
+            $k = $encoder((string) $k);
+            if (!is_array($v)) {
+                $qs .= $k;
+                $v = is_bool($v) ? $castBool($v) : $v;
+                if ($v !== null) {
+                    $qs .= '='.$encoder((string) $v);
+                }
+                $qs .= '&';
+            } else {
+                foreach ($v as $vv) {
+                    $qs .= $k;
+                    $vv = is_bool($vv) ? $castBool($vv) : $vv;
+                    if ($vv !== null) {
+                        $qs .= '='.$encoder((string) $vv);
+                    }
+                    $qs .= '&';
+                }
+            }
+        }
+
+        return $qs ? substr($qs, 0, -1) : '';
+    }
+}

+ 47 - 0
api-test/config.php

@@ -0,0 +1,47 @@
+<?php
+
+/**
+ * api-test 配置:测试服务器切换 + 鉴权 token。
+ *
+ * 服务器切换的优先级(高 → 低):
+ *   1. 命令行 `php run.php --server=<alias|完整 base URL>`
+ *   2. 环境变量 `API_TEST_BASE`(完整 base URL,覆盖 alias)
+ *   3. 本文件的 `default_server`
+ *
+ * alias 对齐 openapi/public/assets/protocol/main.yaml 里的 servers。
+ * 第一个 `/api` 是同源地址,CLI 客户端用不到,故略。
+ *
+ * token(测试 /v3/me/* 用)优先级:
+ *   1. `php run.php --token=...`
+ *   2. 环境变量 `API_TEST_TOKEN`
+ *   3. 都没有时,用下面的 username/password 调 `POST /v2/sign-in` 现换一个 token
+ *
+ * username/password(登录换 token 用)优先级:
+ *   1. `php run.php --username=... --password=...`
+ *   2. 环境变量 `API_TEST_USERNAME` / `API_TEST_PASSWORD`
+ *
+ * 写操作(POST/DELETE)在**所有服务器上都真实执行**,包括 prod——会产生垃圾数据,
+ * 由测试账号定期清理。
+ */
+
+return [
+    // alias => 完整 base URL(含 /api 前缀)
+    'servers' => [
+        'local'   => 'http://127.0.0.1:8000/api',
+        'staging' => 'https://staging.wikipali.org/api',
+        'next'    => 'https://next.wikipali.org/api',
+        'prod'    => 'https://www.wikipali.org/api',
+        'next-cn' => 'https://next.wikipali.cc/api',
+        'prod-cn' => 'https://www.wikipali.cc/api',
+    ],
+    'default_server' => 'local',
+
+    // bearer token;没有则 /v3/me/* 的“带 token”用例会 FAIL(不跳过)。
+    'token' => getenv('API_TEST_TOKEN') ?: null,
+
+    // 登录凭据:sign-in 用 username(也可以是 email)+ password。
+    'username' => getenv('API_TEST_USERNAME') ?: null,
+    'password' => getenv('API_TEST_PASSWORD') ?: null,
+
+    'timeout' => 20,
+];

+ 26 - 0
api-test/fixtures.php

@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * v3 用例的种子数据。
+ *
+ * 不同环境(local / staging / prod)数据不同,必要时按环境覆盖。
+ * 当前取值来自本地开发库实测 + openapi spec 里的 example。
+ */
+
+return [
+    // 一个 translation 类型、有翻译进度与译文数据的 channel
+    'channel' => '4414c96e-4903-11ef-af72-571c3fee08e6',
+
+    // reactions / reactions/tally 用的 target(collection)
+    'target_id' => '5b4511c1-c99f-47e7-9798-97b9e5229e18',
+
+    // tipitaka-related-paragraphs 的两组互斥坐标
+    'book'      => 1,
+    'para'      => 2,
+    'book_name' => 'dn1',
+    'cs_para'   => 4,
+
+    // 搜索关键词 + 一条真实 OpenSearch 文档 id(search/{id} 的 200 用例)
+    'search_q'       => 'dukkha',
+    'search_doc_id'  => 'term_f8afdcaa-ad68-4795-b3d8-d5f12bdb3a5f',
+];

+ 124 - 0
api-test/run.php

@@ -0,0 +1,124 @@
+<?php
+
+/**
+ * api-test 入口:装配配置 → 加载用例 → 运行 → 报告。
+ *
+ * 用法:
+ *   php run.php [--server=<alias|URL>] [--token=<bearer>] [--username=<u> --password=<p>] [--filter=<子串>] [--list]
+ *
+ * 例:
+ *   php run.php                          # 默认 local
+ *   php run.php --server=staging
+ *   php run.php --server=https://foo/api
+ *   php run.php --filter=search
+ *   php run.php --list                   # 列出可用服务器
+ *   php run.php --username=alice --password=secret   # 调 /v2/sign-in 换 token
+ *
+ * 退出码:0 全绿;1 有失败/错误;2 参数或加载错误。
+ */
+
+error_reporting(E_ALL & ~E_DEPRECATED);
+
+$opts = [];
+foreach (array_slice($argv, 1) as $arg) {
+    if (preg_match('/^--([^=]+)(?:=(.*))?$/', $arg, $m)) {
+        $opts[$m[1]] = $m[2] ?? true;
+    }
+}
+
+// --list:只打印服务器清单
+if (isset($opts['list'])) {
+    $cfg = require __DIR__ . '/config.php';
+    echo "可用服务器(--server=alias,默认 {$cfg['default_server']}):\n";
+    foreach ($cfg['servers'] as $alias => $url) {
+        printf("  %-8s %s\n", $alias, $url);
+    }
+    echo "\n也可直接 --server=<完整 base URL>。\n";
+    exit(0);
+}
+
+// 解析服务器
+$config = require __DIR__ . '/config.php';
+$servers = $config['servers'];
+$serverArg = is_string($opts['server'] ?? null) ? $opts['server'] : null;
+$envBase = getenv('API_TEST_BASE') ?: null;
+
+if ($envBase !== null) {
+    $config['base_url'] = rtrim($envBase, '/');
+} elseif ($serverArg !== null && isset($servers[$serverArg])) {
+    $config['base_url'] = $servers[$serverArg];
+} elseif ($serverArg !== null && preg_match('#^https?://#', $serverArg)) {
+    $config['base_url'] = rtrim($serverArg, '/');
+} else {
+    $config['base_url'] = $servers[$config['default_server']];
+}
+
+// token 覆盖
+if (is_string($opts['token'] ?? null) && $opts['token'] !== '') {
+    $config['token'] = $opts['token'];
+}
+
+// username/password 覆盖
+if (is_string($opts['username'] ?? null) && $opts['username'] !== '') {
+    $config['username'] = $opts['username'];
+}
+if (is_string($opts['password'] ?? null) && $opts['password'] !== '') {
+    $config['password'] = $opts['password'];
+}
+
+$GLOBALS['config'] = $config;
+$GLOBALS['fixtures'] = require __DIR__ . '/fixtures.php';
+
+require __DIR__ . '/src/bootstrap.php';
+
+// 没给 token 但给了账号密码 → 调 /v2/sign-in 现换一个
+if (empty($config['token']) && $config['username'] && $config['password']) {
+    try {
+        $config['token'] = login($config['username'], $config['password']);
+        $GLOBALS['config']['token'] = $config['token'];
+        echo "已用 {$config['username']} 登录(/v2/sign-in)换取 token\n\n";
+    } catch (Fail $e) {
+        echo "登录失败:{$e->getMessage()}(需登录的用例将 FAIL)\n\n";
+    }
+}
+
+// 加载用例
+$caseFiles = glob(__DIR__ . '/cases/*.php') ?: [];
+sort($caseFiles);
+foreach ($caseFiles as $file) {
+    require $file;
+}
+
+$tests = $GLOBALS['__tests'];
+$filter = is_string($opts['filter'] ?? null) ? $opts['filter'] : null;
+
+$pass = $fail = $skip = $error = 0;
+$start = microtime(true);
+
+printf("目标服务器: %s\n\n", $config['base_url']);
+
+foreach ($tests as [$name, $fn]) {
+    if ($filter !== null && ! str_contains($name, $filter)) {
+        continue;
+    }
+    try {
+        $fn();
+        $pass++;
+        echo "  ✅ PASS  {$name}\n";
+    } catch (Skip $e) {
+        $skip++;
+        echo "  ⏭️  SKIP  {$name}  ({$e->getMessage()})\n";
+    } catch (Fail $e) {
+        $fail++;
+        echo "  ❌ FAIL  {$name}\n        {$e->getMessage()}\n";
+    } catch (Throwable $e) {
+        $error++;
+        echo "  💥 ERROR {$name}\n        " . get_class($e) . ': ' . $e->getMessage() . "\n";
+    }
+}
+
+$total = $pass + $fail + $skip + $error;
+$elapsed = round(microtime(true) - $start, 2);
+printf("\n%d 用例:%d 通过 / %d 失败 / %d 跳过 / %d 错误(%.2fs)\n", $total, $pass, $fail, $skip, $error, $elapsed);
+
+exit($fail === 0 && $error === 0 ? 0 : 1);

+ 199 - 0
api-test/src/bootstrap.php

@@ -0,0 +1,199 @@
+<?php
+
+/**
+ * 测试引导:加载生成的 Guzzle 客户端 + 注册/断言 helper。
+ * 纯 PHP,无测试框架,不依赖 Laravel。
+ */
+
+require_once __DIR__ . '/../client/vendor/autoload.php';
+
+use GuzzleHttp\Client;
+use \WikipaliApi\ApiException;
+use \WikipaliApi\Configuration;
+
+final class Fail extends RuntimeException
+{
+}
+
+final class Skip extends RuntimeException
+{
+}
+
+$GLOBALS['__tests'] = [];
+
+function test(string $name, callable $fn): void
+{
+    $GLOBALS['__tests'][] = [$name, $fn];
+}
+
+function skip(string $reason): void
+{
+    throw new Skip($reason);
+}
+
+function fail(string $message): void
+{
+    throw new Fail($message);
+}
+
+function config(): array
+{
+    return $GLOBALS['config'];
+}
+
+function fixtures(): array
+{
+    return $GLOBALS['fixtures'];
+}
+
+/** 造一个已指向目标服务器的 Configuration;传 $token 时带 bearer。 */
+function makeConfig(?string $token = null): Configuration
+{
+    $c = new Configuration();
+    $c->setHost(config()['base_url']);
+    if ($token !== null) {
+        $c->setAccessToken($token);
+    }
+
+    return $c;
+}
+
+/** 实例化生成的 Api 类。$class 传完整 FQCN,如 \WikipaliApi\Api\HeartbeatApi::class */
+function api(string $class, ?string $token = null): object
+{
+    // http_errors 保持默认 true:生成的客户端靠 RequestException→ApiException 这条链
+    // 把非 2xx 的 body 塞进 ApiException(尤其 204-only 的 DELETE,它没有显式状态检查,
+    // 关掉 http_errors 会让错误 body 丢失)。
+    $http = new Client(['timeout' => config()['timeout']]);
+
+    return new $class($http, makeConfig($token));
+}
+
+/**
+ * 统一执行一次请求,返回 ['status'=>int, 'body'=>?array, 'headers'=>array]。
+ * $fn 必须返回 WithHttpInfo 的三元组 [$content, $status, $headers]。
+ * 非 2xx 时生成的客户端抛 ApiException,这里捕获并解出状态码与 body。
+ */
+function request(callable $fn): array
+{
+    try {
+        [$content, $status, $headers] = $fn();
+
+        return ['status' => $status, 'body' => normalize($content), 'headers' => $headers];
+    } catch (ApiException $e) {
+        $status = $e->getCode();
+        // 网络层错误(DNS/连接/超时)时,生成客户端把异常包成 ApiException 但 code 不是
+        // 合法 HTTP 状态(可能是 0,真实 curl 错误在 message 里);转成可读的 Fail。
+        if ($status < 100 || $status > 599) {
+            throw new Fail(sprintf('网络错误(服务器不可达):%s', $e->getMessage()));
+        }
+        $body = json_decode((string) $e->getResponseBody(), true);
+
+        return ['status' => $status, 'body' => is_array($body) ? $body : null, 'headers' => $e->getResponseHeaders()];
+    }
+}
+
+/**
+ * 原始 HTTP 请求:绕过生成客户端的必填参数校验,用于「缺参/非法组合」这类负向用例。
+ * $path 以 / 开头(如 /v3/reactions),base URL 已含 /api 前缀。
+ */
+function raw(string $method, string $path, ?array $query = null, ?array $json = null, ?string $token = null): array
+{
+    $http = new Client(['timeout' => config()['timeout'], 'http_errors' => false]);
+    $opts = ['headers' => []];
+    if ($query !== null) {
+        $opts['query'] = $query;
+    }
+    if ($json !== null) {
+        $opts['json'] = $json;
+    }
+    if ($token !== null) {
+        $opts['headers']['Authorization'] = 'Bearer ' . $token;
+    }
+    try {
+        $resp = $http->request($method, config()['base_url'] . $path, $opts);
+    } catch (\GuzzleHttp\Exception\GuzzleException $e) {
+        // 网络层错误(DNS 解析失败、连接拒绝、超时等)→ 转成 Fail,别让 Guzzle 异常
+        // 一路冒成 fatal error。消息带上目标,方便定位是哪个服务器连不上。
+        throw new Fail(sprintf('网络错误(%s %s%s):%s', strtoupper($method), config()['base_url'], $path, $e->getMessage()));
+    }
+    $body = json_decode((string) $resp->getBody(), true);
+
+    return ['status' => $resp->getStatusCode(), 'body' => is_array($body) ? $body : null, 'headers' => $resp->getHeaders()];
+}
+
+function normalize(mixed $content): mixed
+{
+    if ($content === null || is_array($content)) {
+        return $content;
+    }
+    if (is_object($content)) {
+        return json_decode((string) json_encode($content), true);
+    }
+
+    return $content;
+}
+
+/**
+ * 调 v2 登录接口换 bearer token。
+ * `POST /v2/sign-in`(username 可以是用户名或邮箱),成功返回 {ok,data:JWT},
+ * 失败也是 JSON({ok:false,message})且 HTTP 可能 200 或 400,所以只看 ok 字段。
+ */
+function login(string $username, string $password): string
+{
+    $res = raw('POST', '/v2/sign-in', null, ['username' => $username, 'password' => $password]);
+    if (($res['body']['ok'] ?? false) === true && is_string($res['body']['data'] ?? null) && $res['body']['data'] !== '') {
+        return $res['body']['data'];
+    }
+    fail(sprintf('登录失败(HTTP %d):%s', $res['status'], $res['body']['message'] ?? '未知错误'));
+}
+
+/** 取当前 token;没有就 FAIL(需登录用例不跳过,缺 token 就是失败)。 */
+function required_token(): string
+{
+    $token = config()['token'];
+    if (! $token) {
+        fail('缺少 token:请用 --token= 或 --username/--password(自动调 /v2/sign-in)');
+    }
+
+    return $token;
+}
+
+/* ---- 断言 ---- */
+
+function assert_status(int $expected, array $res, string $what): void
+{
+    if ($res['status'] !== $expected) {
+        fail(sprintf(
+            '%s:期望 HTTP %d,实际 %d,body=%s',
+            $what,
+            $expected,
+            $res['status'],
+            substr((string) json_encode($res['body'], JSON_UNESCAPED_UNICODE), 0, 400)
+        ));
+    }
+}
+
+function assert_has_key(string $key, mixed $value, string $what): void
+{
+    if (! is_array($value) || ! array_key_exists($key, $value)) {
+        fail(sprintf('%s:响应缺少字段 `%s`,body=%s', $what, $key, substr((string) json_encode($value, JSON_UNESCAPED_UNICODE), 0, 400)));
+    }
+}
+
+function assert_eq(mixed $expected, mixed $actual, string $what): void
+{
+    if ($expected !== $actual) {
+        fail(sprintf('%s:期望 %s,实际 %s', $what, var_export($expected, true), var_export($actual, true)));
+    }
+}
+
+/** 断言失败响应是 RFC 9457 Problem Details,且 status 与 HTTP 码一致。 */
+function assert_problem(array $res, string $what): void
+{
+    $b = $res['body'];
+    if (! is_array($b) || ! isset($b['type'], $b['title'], $b['status'])) {
+        fail(sprintf('%s:不是 Problem Details(缺 type/title/status),body=%s', $what, substr((string) json_encode($b, JSON_UNESCAPED_UNICODE), 0, 400)));
+    }
+    assert_eq($res['status'], (int) $b['status'], $what . ':Problem.status 与 HTTP 状态码不一致');
+}

+ 169 - 0
api-test/tools/filter-v3.php

@@ -0,0 +1,169 @@
+<?php
+
+/**
+ * 从打包后的全量 OpenAPI spec 里切出仅 /v3/* 的部分,供 openapi-generator
+ * 生成精简的 PHP 客户端(目前只测 /api/v3 路由)。
+ *
+ * 用法:
+ *   php tools/filter-v3.php spec/openapi.json spec/openapi-v3.json
+ *
+ * 逻辑:保留 /v3/* 路径,并递归收集它们引用的 components(schemas / responses /
+ * parameters / requestBodies / examples / headers / securitySchemes / links /
+ * callbacks),其余组件与路径一律丢弃。
+ */
+
+if (PHP_SAPI !== 'cli') {
+    fwrite(STDERR, "只允许 CLI 运行\n");
+    exit(2);
+}
+
+[$script, $in, $out] = array_pad($argv, 3, null);
+if (!$in || !$out || !is_file($in)) {
+    fwrite(STDERR, "用法: php {$script} <in.json> <out.json>\n");
+    exit(2);
+}
+
+$spec = json_decode(file_get_contents($in), true);
+if (!is_array($spec) || !isset($spec['paths'])) {
+    fwrite(STDERR, "无法解析 {$in}\n");
+    exit(2);
+}
+
+/** 递归收集节点里所有指向 #/components/ 的 $ref */
+function collectComponentRefs(mixed $node, array &$refs): void
+{
+    if (!is_array($node)) {
+        return;
+    }
+    if (isset($node['$ref']) && is_string($node['$ref']) && str_starts_with($node['$ref'], '#/components/')) {
+        $refs[$node['$ref']] = true;
+        // $ref 节点本身不再展开(其内容在 components 里,会被下一轮收集)
+        return;
+    }
+    foreach ($node as $value) {
+        collectComponentRefs($value, $refs);
+    }
+}
+
+/** 按 "#/components/{category}/{name}" 取节点 */
+function resolveComponent(array $spec, string $ref): mixed
+{
+    $parts = explode('/', ltrim($ref, '#/'));
+    // ['components', category, name]
+    return $spec[$parts[0]][$parts[1]][$parts[2]] ?? null;
+}
+
+// 1) 选出 v3 路径
+$v3Paths = [];
+foreach ($spec['paths'] as $path => $item) {
+    if (str_starts_with((string) $path, '/v3/')) {
+        $v3Paths[$path] = $item;
+    }
+}
+if ($v3Paths === []) {
+    fwrite(STDERR, "spec 里没有 /v3/* 路径\n");
+    exit(2);
+}
+
+// 2) 修正鉴权语义:源 spec 用全局 security 把全部路径标成 bearerAuth,
+//    但 v3 实际只有 /v3/me/* 挂 auth.v3。这里逐 operation 写准,并清掉全局 security。
+foreach ($v3Paths as $path => &$item) {
+    foreach ($item as $method => &$op) {
+        if (!is_array($op) || strtolower($method) === 'parameters') {
+            continue;
+        }
+        $op['security'] = str_starts_with((string) $path, '/v3/me/')
+            ? [['bearerAuth' => []]]
+            : [];
+    }
+    unset($op);
+}
+unset($item);
+
+// 3) 递归收集引用的组件(含 security scheme)
+$refs = ['#/components/securitySchemes/bearerAuth' => true];
+collectComponentRefs($v3Paths, $refs);
+do {
+    $before = count($refs);
+    foreach (array_keys($refs) as $ref) {
+        $node = resolveComponent($spec, $ref);
+        if (is_array($node)) {
+            collectComponentRefs($node, $refs);
+        }
+    }
+} while (count($refs) !== $before);
+
+// 4) 只保留被引用的组件(保留原始分类顺序)
+$categories = ['schemas', 'responses', 'parameters', 'examples', 'requestBodies', 'headers', 'securitySchemes', 'links', 'callbacks'];
+$components = $spec['components'] ?? [];
+$keptComponents = [];
+foreach ($categories as $cat) {
+    if (!isset($components[$cat])) {
+        continue;
+    }
+    foreach ($components[$cat] as $name => $node) {
+        $ref = "#/components/{$cat}/{$name}";
+        if (isset($refs[$ref])) {
+            $keptComponents[$cat][$name] = $node;
+        }
+    }
+}
+
+$result = [];
+foreach (['openapi', 'info', 'servers', 'tags', 'externalDocs'] as $key) {
+    if (isset($spec[$key])) {
+        $result[$key] = $spec[$key];
+    }
+}
+$result['components'] = $keptComponents;
+$result['paths'] = $v3Paths;
+
+// 5) 修掉源 spec 的数组缺 items 问题:generate.php 给 query/body 参数只写了
+//    `type: array` 没写 `items`(严格校验会拒绝)。v3 里的这类参数都是字符串数组
+//    (page_refs / related_id),补成 string items。
+normalizeArraySchemas($result);
+
+// 6) 修掉「spec 与实现不一致」的 3 个端点(搜索/建议/升级):实现仍返回
+//    {success, data:对象} 或 {data:对象},而 spec 写成了 {data:数组, meta}。
+//    按现实修正 200 响应 schema,否则生成的客户端反序列化会抛
+//    "Invalid array 'object[]'"。根因在控制器还没迁到 v3 Resource 信封,
+//    正确的长期修法在 api-v13 侧,这里只是让测试客户端能用。
+applyDriftPatches($result);
+
+file_put_contents($out, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
+fwrite(STDOUT, 'v3 paths: ' . count($v3Paths) . ', components refs: ' . count($refs) . " -> {$out}\n");
+
+/** 递归给缺 items 的 `type: array` schema 补 string items */
+function normalizeArraySchemas(mixed &$node): void
+{
+    if (! is_array($node)) {
+        return;
+    }
+    if (($node['type'] ?? null) === 'array' && ! isset($node['items'])) {
+        $node['items'] = ['type' => 'string'];
+    }
+    foreach ($node as &$value) {
+        normalizeArraySchemas($value);
+    }
+    unset($value);
+}
+
+/** 修正 search / search-suggest / upgrade 的 200 响应 schema,使其与实现一致 */
+function applyDriftPatches(array &$spec): void
+{
+    $object = ['type' => 'object'];
+    $dataObject = ['type' => 'object', 'properties' => ['data' => $object]];
+    $successData = ['type' => 'object', 'properties' => ['success' => ['type' => 'boolean'], 'data' => $object]];
+
+    foreach (['get', 'post'] as $method) {
+        if (isset($spec['paths']['/v3/search'][$method]['responses']['200']['content']['application/json']['schema'])) {
+            $spec['paths']['/v3/search'][$method]['responses']['200']['content']['application/json']['schema'] = $successData;
+        }
+    }
+    if (isset($spec['paths']['/v3/search-suggest']['get']['responses']['200']['content']['application/json']['schema'])) {
+        $spec['paths']['/v3/search-suggest']['get']['responses']['200']['content']['application/json']['schema'] = $successData;
+    }
+    if (isset($spec['paths']['/v3/upgrade']['get']['responses']['200']['content']['application/json']['schema'])) {
+        $spec['paths']['/v3/upgrade']['get']['responses']['200']['content']['application/json']['schema'] = $dataObject;
+    }
+}

+ 54 - 0
api-test/tools/regen.sh

@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+#
+# 一键重新生成测试客户端:
+#   全量 spec bundle → 切出 /v3 + 打补丁 → openapi-generator 生成 PHP(Guzzle) 客户端 → composer install
+#
+# 依赖:
+#   - Java 17+(openapi-generator 是 jar)
+#   - npx(openapi/ 里已装 @redocly/cli)
+#   - php / composer
+#
+# openapi-generator-cli.jar 通过环境变量 OPENAPI_GENERATOR_JAR 指定,
+# 默认 /tmp/og/openapi-generator-cli.jar。下载方式:
+#   curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.25.0/openapi-generator-cli-7.25.0.jar \
+#     -o /tmp/og/openapi-generator-cli.jar
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+OG_JAR="${OPENAPI_GENERATOR_JAR:-/tmp/og/openapi-generator-cli.jar}"
+COMPOSER_CACHE="${COMPOSER_CACHE_DIR:-/tmp/composer-cache}"
+
+if [ ! -f "$OG_JAR" ]; then
+  echo "找不到 openapi-generator-cli.jar:$OG_JAR" >&2
+  echo "下载:curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.25.0/openapi-generator-cli-7.25.0.jar -o /tmp/og/openapi-generator-cli.jar" >&2
+  exit 1
+fi
+
+echo "1/4  bundle 全量 spec → spec/openapi.json"
+(cd "$ROOT/openapi" && npx redocly bundle public/assets/protocol/main.yaml -o "$ROOT/api-test/spec/openapi.json" --ext json)
+
+echo "2/4  切出 /v3 + 打补丁 → spec/openapi-v3.json"
+php "$ROOT/api-test/tools/filter-v3.php" "$ROOT/api-test/spec/openapi.json" "$ROOT/api-test/spec/openapi-v3.json"
+
+echo "3/4  openapi-generator 生成 PHP 客户端 → client/"
+rm -rf "$ROOT/api-test/client"
+java -jar "$OG_JAR" generate -g php \
+  -i "$ROOT/api-test/spec/openapi-v3.json" \
+  -o "$ROOT/api-test/client" \
+  --additional-properties=invokerPackage=WikipaliApi,packageName=wikipali/api-client,artifactVersion=1.0.0
+
+# 清掉 openapi-generator 的样板文件(.openapi-generator-ignore 只能防止覆盖已编辑文件,
+# 不能阻止生成,所以这里生成后直接删,只留 lib/ + composer.json)
+rm -rf \
+  "$ROOT/api-test/client/docs" \
+  "$ROOT/api-test/client/test" \
+  "$ROOT/api-test/client/README.md" \
+  "$ROOT/api-test/client/.travis.yml" \
+  "$ROOT/api-test/client/phpunit.xml.dist" \
+  "$ROOT/api-test/client/.php-cs-fixer.dist.php" \
+  "$ROOT/api-test/client/git_push.sh"
+
+echo "4/4  composer install(Guzzle)"
+(cd "$ROOT/api-test/client" && COMPOSER_CACHE_DIR="$COMPOSER_CACHE" composer install --no-dev --no-interaction --no-progress)
+
+echo "完成。运行:php api-test/run.php --server=local"

+ 4 - 4
api-v13/app/Services/V3/TipitakaReadingService.php

@@ -22,7 +22,7 @@ class TipitakaReadingService
     /**
      * 字节模式下单块的字节上限。请求超过这个数按这个数算,不报错。
      */
-    public const MAX_BYTES_PER_PAGE = 5000;
+    public const MAX_BYTES_PER_PAGE = 50000;
 
     /**
      * 单块最多返回的段落数,两种单位都受它约束。
@@ -57,14 +57,14 @@ class TipitakaReadingService
             : min($pageSize, self::MAX_PARAGRAPHS_PER_PAGE);
 
         $candidates = (clone $scope)
-            ->when($filters['after'] ?? null, fn (Builder $q, string $after) => $this->applyCursor($q, $after))
+            ->when($filters['after'] ?? null, fn(Builder $q, string $after) => $this->applyCursor($q, $after))
             ->distinct()
             ->select(['book_id', 'paragraph'])
             ->orderBy('book_id')
             ->orderBy('paragraph')
             ->limit($want + 1)
             ->get()
-            ->map(fn ($row) => ['book' => (int) $row->book_id, 'para' => (int) $row->paragraph])
+            ->map(fn($row) => ['book' => (int) $row->book_id, 'para' => (int) $row->paragraph])
             ->all();
 
         $hasMoreBeyondWant = count($candidates) > $want;
@@ -240,7 +240,7 @@ class TipitakaReadingService
             return [];
         }
 
-        $countIn = fn (Builder $q): int => (int) (
+        $countIn = fn(Builder $q): int => (int) (
             $q->selectRaw('count(distinct (book_id, paragraph)) as aggregate')->first()?->aggregate ?? 0
         );
 

+ 151 - 16
api-v13/documents/related-paragraphs-redesign.md

@@ -1,9 +1,13 @@
 # related_paragraphs 段落关联体系 修复设计文档
 
-> 状态:已评审;2026-03 对当前库(visuddhinanda_20260311)全量实测复核后修订,待实施
+> 状态:**第一批已实施**(2026-09-09,导出器 + 检测器落地于 `api-v13/scripts/related/`,
+> 第一版数据在影子表 `related_paragraphs_v1`,生产表未动);章回绕与 LLM 补齐两批待做。
 > 背景:2025-08 数据审计发现 related_paragraphs 存在多类系统性数据错误。
-> 原始生成链路(VRI html 锚点 → cs6_para.csv → Laravel 导入)中的 csv 文件已丢失,
-> 决定以 wbw_templates 为唯一数据源重新导出第一版数据,再用补丁文件修正历史问题。
+> 原始生成链路是 VRI html 锚点 → cs6_para.csv → Laravel 导入;决定以 wbw_templates
+> 为生成源重新导出第一版数据,再用补丁文件修正历史问题。
+>
+> **实施复核勘误见 §9**——其中 §6.1 关于 csv 已丢失的判断、§4.3 E8 的判据、
+> §6.2 关于义注 `{mula}_{cs}` 标记的语义三处与实测不符,正文已就地改正。
 
 ---
 
@@ -40,9 +44,11 @@ wbw_templates                (数据源头,逐词表)
   │                 按历史形态保留备注,导出器以实测词表为准)
   │   区间标记:      para151-152 / para151-152_sn4  (1 个 vri 段 ↔ 多个 cs,展开多行)
   │   缩写区间:      para292-3  = 292-293(M 补齐 N 的前缀)
-  │   义注文件:      dn1_0 / dn1_1({mula}_{cs},义注段挂 mūla 名下)
-  │   章节标记:      kn10_2 / an1_5(mūla 文件内的章界)
-  ▼  (旧链路经 cs6_para.csv ——已丢失,废弃)
+  │   章节标记:      dn1_0 / dn1_1 / kn10_2 / an5_1_1({book}_{章号},可多级)
+  │                 实测:mūla 与义注文件里形态相同,都是章界而非 cs 值;
+  │                 唯 _0 特殊,表示义注序言(→ {book}_A)。见 §9 勘误 3
+  │   书界标记:      dn1 / an3(纯书名,文件内的书切换点,93 行)
+  ▼  (旧链路经 cs6_para.csv ——仍存于 clove 仓库,含全部历史错误,只作基线不作源)
 related_paragraphs           (本表,631,838 行)
   book, para                 VRI 文件号 1-217 + 段落号
   book_id ──────────────────▶ book_titles.sn
@@ -85,7 +91,7 @@ pali_texts                   (全文)
 - 正常情况:一个 book_id 内只有一个 book_name 且 cs_para 单调递增(如 book=93)。
   现发现部分书不是:一个 book_id 内出现重复的 cs_para
 - related_paragraphs 数据当年从 wbw_templates(进而来自 VRI html 锚点)导入,
-  具体过程已不可考,cs6_para.csv 原始文件已丢失
+  具体过程已不可考,但 cs6_para.csv 本身仍在(见 §9),可用作逐段落比对基线
 
 ### 4.2 审计确认的正确性判据(修正后的业务模型)
 
@@ -108,8 +114,8 @@ pali_texts                   (全文)
 | E5 | book_id=0 未赋值 | 354 行(201 本各 1-3 行);book 189 审计时整本 1,572 行为 0,现已回填(1,570 行 book_id=230,仅书前页 2 行为 0) | 各文件 para 1-2 书前页(可接受)。sn=280/281 仍无 book_id 行:book 139 para≥925 整段归入 sn 157、book 214 para≥2162 整段归入 sn 276,随 book_id 层重跑修复。另 book_titles sn=230 title 误写 sādhuvilāsinī(与 sn=229 重名,实际应为 sīlakkhandhavaggaabhinavaṭīkā,已由 pali_texts level-1 标题证实) |
 | E6 | 覆盖缺口 | 3,466 段无行 | 主体为缩写区间丢失(见 E7),其余为 wbw_templates 整段缺失 |
 | E7 | **缩写区间丢失**(本次审计新发现) | 1,517 个标记段落 / 44 本:其中 1,292 段(85.2%)无 related 行,其余 225 段带行但 cs 全为乱码值(与 E1 同根) | VRI 缩写语法 `para292-3`(=292-293)旧生成器不识别:多数直接跳过,少数误解析为废值(如 `para32-3`→11,749、`para15-8`→44,058) |
-| E8 | **书边界污染平铺** | kaṅkhāvitaraṇī 义注 1,499 行 cs 恒 75、ṭīkā 669 行 cs 恒 0 等 | 换书(book_id 切换)后无标记,旧生成器无限沿用上一个 cs 值 |
-| E9 | 无标记书 | 16 本(book 8,15,16,25,37,39-42,44,45,47,48,56,61,129)related 有行但 wbw_templates 零标记 | VRI 源文件无锚点。其中 book 129(theragāthā-aṭṭhakathā 后半)等存在真实注释关系,需外部手段补齐 |
+| E8 | **书边界污染平铺** | 判据收窄后实测**仅 1 段**:book 207 / book_id 259 kaṅkhāvitaraṇī 义注 1,499 行 cs 恒 75(见 §9 勘误 2) | 换书(book_id 切换)后无标记,旧生成器无限沿用上一个 cs 值 |
+| E9 | 无标记书 | 16 本(book 8,15,16,25,37,39-42,44,45,47,48,56,61,129)related 有行但 wbw_templates 零标记 | VRI 源文件无锚点。其中 book 129(theragāthā-aṭṭhakathā 后半)存在真实注释关系——外部手段已找到:cs6_para.csv 里它有 6,580 非零行,直接回填即可,不需 LLM。其余 15 本 csv 里也是全零,源头就没有 |
 | E10 | 自造名体系 | NK(book 1,namakkāra 自注释对)、subo(book 10↔11)、dict(book 21↔9) | 非 SC 体系书对的自造 album 名,连接各自 mūla↔注释。架构正确(与 dn1/dn1_A 同构),保留不动;仅其中的乱码/缩写问题随全局规则修复 |
 
 ## 5. 全库书况初步分析表
@@ -122,7 +128,8 @@ pali_texts                   (全文)
 - **缩写丢失 n 处**:缩写区间标记段落无行 → 新导出器自动修复
 - **章回绕 n 次**:cs 重置计数 → 章后缀方案处理(注:含少量合法多轮注释结构
   如 NK 的 ṭīkā 两轮注释,处理时用标题层级甄别)
-- **无标记污染**:cs 值种类极少但行数巨大(书边界沿用平铺)→ 导出器留空 + LLM 补丁
+- **无标记污染**:整段无 cs 标记却被写进非零 cs(书边界沿用平铺)→ 导出器输出 cs=0 + LLM 补丁
+  (注:表内按旧判据「cs 值种类极少」标注的几本实为章回绕误判,见 §9 勘误 2)
 - **全书无标记**:wbw_templates 无任何 para 标记但 related 有行 → LLM 补丁或维持现状
 - **无 book_id 行**:sn 存在但无任何 book_id 归属行(行均被归入同文件前一册)
 
@@ -433,7 +440,11 @@ wbw_templates(唯一数据源,含全部 .ctl. 标记)
 ```
 
 关键决策记录:
-- **cs6_para.csv 废弃**,不再寻找;一切从 wbw_templates 重放
+- **cs6_para.csv 不作生成源**:它是现表的来源、含全部历史错误,一切从 wbw_templates
+  重放。但它并未丢失(`github.com/iapt-platform/clove/pali_title/cs6_para.csv`,523,283 行),
+  已归档为逐段落 diff 基线,并作为 book 129 的唯一回填源(见 §9)
+- **段落全集取 pali_texts 而非 wbw_templates**:pali_texts 523,284 段是严格超集,
+  wbw 少的 7,171 段散在 21 本里;wbw 只负责提供标记
 - **维持 VRI 编号方案**(章内局部编号),SC 数据不采用(SC 层级树与加后缀方案无本质
   区别,且 SC 为 Mahāsaṅgīti 独立校勘本,文本与 VRI 不对齐,无法直接落到 VRI 段落)
 - **回绕处理(分两类)**:
@@ -462,6 +473,7 @@ wbw_templates(唯一数据源,含全部 .ctl. 标记)
 | `paraN-M`(M 位数<N,补齐后>N) | 缩写区间,前缀补齐展开(para292-3→292,293) |
 | `paraN-M_book`(缩写+后缀组合,实测 989 行,如 `para1008-9_sn5`) | 先按缩写规则展开 N..M,再以后缀定 book_name |
 | `paraN-M-K...` | 链式首尾法展开 N..tail(para292-3-6→292..296);实测仅 4 行,旧数据已正确展开(book 189 para 827 → 292-296) |
+| `paraN-M`(M < N 且位数相同,全库 3 行) | 倒序区间=高位笔误,丢弃右端高位、只用低位重新前缀补齐(`para504-407`→504-507、`para706-608`→706-708),并记 issue 人工复核。见 §9 勘误 4 |
 | `paraN-`(M 空,全库仅 1 处:book 11 para 1577) | 留空 + issue(`truncated_marker`),人工定 |
 | 解析值超值域保护阈值 | 拒绝输出 + issue(`cs_out_of_range`),防乱码 |
 | 页码/卷标 `P/M/V/T/O` × 0-5(实测 26 家族 165,907 行) | 忽略,本轮不处理 |
@@ -472,7 +484,7 @@ wbw_templates(唯一数据源,含全部 .ctl. 标记)
 | 输入 | 处理 |
 |---|---|
 | 延续段(同书内有前一标记) | 沿用;区间沿用则整区间展开(含巨区间 844 行场景) |
-| 书边界(book_id 切换)后的无标记段 | **留空 + issue(`no_marker_after_book_switch`)** ⭐ 防污染核心规则 |
+| 书边界(book_id 切换)后的无标记段 | **输出 cs=0 + issue(`segment_without_markers`)** ⭐ 防污染核心规则。实测不能整段不输出:这些段落多是 cs=0 占位行(E4 的无注释体系书、义注序言、书前页),删掉会白丢 20,433 段覆盖,而 `index()` 取锚点要求 `cs_para > 0`,cs=0 行不产生任何配对 |
 | 文件开头段(para 1-2) | cs=0、book_id=0(维持现状语义) |
 | wbw_templates 整段无行 | 自然无输出 + issue(`missing_wbw`) |
 
@@ -482,9 +494,9 @@ wbw_templates(唯一数据源,含全部 .ctl. 标记)
 |---|---|
 | `paraN_book` 后缀 | book_name = 后缀 |
 | 无后缀标记 | 继承当前 book_name;文件开头继承不到 → 留空 + issue |
-| `{mula}_{0}`(义注文件) | 进入 `{mula}_A` 序言模式(重现 dn1_A 正确先例) |
-| `{mula}_{N>0}`(义注文件) | 退出序言模式,book_name=mūla 名 |
-| `{book}_{N}`(mūla 文件) | 章切换事件,触发章后缀上下文 |
+| `{book}_{0}` | 进入 `{book}_A` 序言模式(重现 dn1_A 正确先例) |
+| `{book}_{N>0}` | 退出序言模式;章切换事件,触发章后缀上下文。**不产生 cs 值**——mūla 与义注文件同形同义,见 §9 勘误 3 |
+| 序言段内出现 `paraN_{mula}` 后缀 | 只赋 cs、不夺名,book_name 保持 `{book}_A`。序言里带这种标记是正常体例(book 130 的 `mn1_0` 序言内就有 `para1_mn1`),序言只由 `{book}_{N>0}` 结束 |
 | NK/subo/dict 等自造名 | 由书级配置表提供(保兼容,不重新发明);books 107/143/144 的 kn10 家族导出名同样由书级配置表决定(补丁 old 值必须与之匹配,见 §6.5) |
 
 文件类型(义注 vs mūla)用 tag(aṭṭhakathā/ṭīkā vs mūla/pāḷi)消解
@@ -624,6 +636,129 @@ saṅghādisesa 从 1 重计),修复时按组加后缀(vin6_pr1 型),
 - 标记词表:缩写+后缀组合 `para1008-9_sn5` 型 989 行;链式标记 4 行(旧数据已正确
   展开);截断 `para179-` 仅 book 11 para 1577 一处;页码标记 26 家族 165,907 行
   (本轮忽略);纯书名标记 93 行(书上下文事件);巨区间 para308-1151 现为 844 行。
-- 基础环境:cs6_para.csv 确认已不存在(storage/resources/pali_title 目录缺失);
+- 基础环境:本地 storage/resources/pali_title 目录缺失,但 csv 在 clove 仓库里(见 §9);
   281 个 sn 与 book_titles 一致;tags 的 8 个类型名齐备,文件类型消歧可用;
   新导出器 command 尚未实现,本文档仍处于待实施状态。
+
+---
+
+## 9. 实施复核勘误与新发现(2026-09-09,第一批实施)
+
+工具链落地于 `api-v13/scripts/related/`,第一版数据在影子表 `related_paragraphs_v1`
+(638,076 行),生产表已备份为 `related_paragraphs_bak_20260909` 且未改动。
+以下是实施中与本文档原判断不符、或本文档未记录的事实,正文相应处已就地改正。
+
+### 勘误 1:cs6_para.csv 并未丢失
+
+在 `github.com/iapt-platform/clove/pali_title/cs6_para.csv`,523,283 行,可直接取得。
+本地 `storage/resources/pali_title` 目录确实缺失,§8 据此推断"文件已不存在"是过头了。
+
+它仍不能当生成源——它是现表的来源,含全部历史错误(`462580` 废值、`kn10_A`/`kn10_B`
+错名一个不少)。但两个用途不可替代:
+
+- **逐段落 diff 基线**:它每行一个段落带 `begin,end`,正是重放结果的天然对照
+- **book 129 的唯一回填源**:逐本比对「csv 非零行 vs wbw 标记数」,全库只有 book 129
+  是"csv 有 6,580 非零行、wbw 零标记"。E9 其余 15 本 csv 里也是全零,源头就没有。
+  也就是说这本不需要 LLM,直接回填即可
+
+另:`para504-407` 丢段的完整链路查清了——csv 里原样写着 `begin=504,end=407`,
+旧 Laravel 命令的 `for ($i = $begin; $i <= $end; $i++)` 一圈没进。**丢段发生在导入
+环节的静默空转,不在生成环节**。
+
+### 勘误 2:E8 判据过宽,真污染只有 1 段
+
+按"cs 值种类极少但行数巨大"判会把**章回绕误报成污染**:book 152 有 516 个 cs 标记却
+只有 24 个不同 cs 值,那是 22 次回绕造成的,不是平铺。
+
+污染的准确签名是「整段一个 cs 标记都没有,却被写进了非零 cs」——那个值只能是从上一本
+沿用下来的。按此判,全库只有 **book 207 / book_id 259 kaṅkhāvitaraṇī 一段,1,499 行
+恒 cs=75**。原列的其余几组:
+
+- book 212/273(669 行)、book 180/213(1,219 行)、book 209/261(163 行)cs 恒为 **0**
+  ——0 是"未赋值"不是编造值,不算污染
+- book 152/46/116/154/204 是章回绕(E2),归 E2 处理
+
+### 勘误 3:`{name}_{N}` 是章标记,mūla 与义注文件同形同义
+
+§2 原写"义注文件 `dn1_0 / dn1_1` 是 `{mula}_{cs}`,义注段挂 mūla 名下"。实测不是:
+
+- book 93(mūla)有 `dn1_1..dn1_13`,book 103(义注)有 `dn1_0..dn1_13`,**形态与语义都相同**,
+  都是章界,都不产生 cs 值
+- book 103 para 1460 的 `dn1_12` 并没有让该段 cs=12——该段 cs=499,来自 para 标记
+
+义注段挂 mūla 名下靠的是 **`paraN_{mula}` 后缀**(如 `para4_dn1`),不是章标记。
+唯一特殊的是 `_0`:它表示义注序言(Ganthārambhakathā),进入 `{book}_A` 模式。
+
+配套的两条实测语义:
+
+- **序言由 `{book}_{N>0}` 结束,不由 cs 标记结束**。book 103 的 `dn1_A` 止于 `dn1_1`@199,
+  与旧表的 198/199 分界严丝合缝
+- **序言段内出现 `paraN_{mula}` 是正常体例**。book 130 的 `mn1_0` 序言(paras 5-46)里就有
+  `para1_mn1`@40,book 192 更有一长串。这些后缀只赋 cs、不夺名,段落仍归 `{book}_A`
+
+### 勘误 4:两类本文档未记录的标记形态
+
+全库 330,267 个锚点(`type='.ctl.' and style='#a#'`)经解析器 100% 归类、零 unknown:
+page 167,647 / single 156,919 / interval 4,604 / chapter 1,003 / book_tag 93 / truncated 1。
+其中两类原文档没有:
+
+- **倒序区间 3 行**:`para504-407`(book 103 para 1464)、`para706-608`(book 176 para 753)。
+  是高位笔误,低位正确:前者前邻 502-503、后邻 508,真值 504–507;后者前邻 703-705、
+  后邻 709,真值 706–708。规则是丢弃右端高位、只用低位重新前缀补齐
+- **多级章标记 247 行 / 13 本**:`an5_1_1` 型。义注书 100-102 与 mūla 书 82-92 **共用同一套
+  编号**,这是 §6.1 跨侧章界对齐现成的抓手,做章后缀时应优先用它而非推标题
+
+### 新发现 5:段落全集是 pali_texts 不是 wbw_templates
+
+pali_texts 523,284 段是严格超集(正是 csv 的行数),wbw_templates 只有 516,113 段,
+少的 7,171 段散在 21 本里。按 wbw 取段落全集会白丢这些段落的行。wbw 只负责提供标记。
+
+### 新发现 6:两处书名归属错误
+
+- **book 107(apadāna-aṭṭhakathā)整本属 kn10**。旧表切成 `kn10_A`(para 3-2141) +
+  `kn10_B`(2142-3875) 并称后者对应 therī——标题证伪:para 2146 的标题是
+  `(Dutiyo bhāgo) Therāpadānaṃ`,那是 therāpadāna 的**第二卷**;全书只有一个 level-1
+  标题「Apadāna-aṭṭhakathā」,vagga 编到 56(Yasavaggo)也正是 therāpadāna 的范围,
+  书内无任何 therī 标题。therīapadāna 另在 book 144 para 6534 起。已记入
+  `book_config.json` 的 `overrides`
+- **book 177 内含四部书**:abhidhammāvatāro(sn 205)、nāmarūpaparicchedo(206)、
+  paramatthavinicchayo(207)、saccasaṅkhepo(208)。只有第一部是 `abhi8`,
+  后三部本就没有 SC 书名。生成书级配置表时若过滤掉空书名,`abhi8` 会越过书界铺满
+  整个文件,把 10,695 行错配进 abhi8 的注释链——空书名必须同样成段
+
+### 新发现 7:book 143/144 的 kn10 错名靠 wbw 自动修复
+
+E3 原以为需要人工改名。实测 wbw 里 book 143 有 `kn10@3`、book 144 有 `kn10@3` 与
+`kn11@6532`,书界写得清清楚楚,按 wbw 重放即自动归位。需人工的只有没有 book_tag 的
+book 107(见新发现 6)。
+
+### 第一批成绩
+
+| 探测器 | 旧表 | v1 |
+|---|---|---|
+| E1 cs 乱码 | 1,242 行 | **0** |
+| E3 kn10 | 0 行 | **25,506 行**(kn10_A/B 清零) |
+| E6 覆盖缺口 | 3,464 段 | **1 段**(仅 book 11 那个截断标记) |
+| E7 缩写区间丢失 | 1,292 段无行 + 225 段废值 | **0 + 0** |
+| E8 污染平铺 | 1,499 行 | **0** |
+| 区间展开不符 | 1,611 | **0** |
+| 镜像判据 | 559/1289/524/918 | **保持** |
+| 零错误信号的书 | 128 本 | **148 本** |
+
+与旧表逐段落 91.8% 相同(480,290 / 523,284 段)。差异全部归因:
+`only_in_old` 1 段(截断标记)、`only_in_new` 3,466 段(区间展开补回,与旧表覆盖缺口
+数吻合)、`cs_changed` 10,371 段(8,600 归零防污染 + 528 新赋值 + **1,290 两侧都非零而
+边界不同**)、`name_changed` 29,156 段(29,146 是 kn10 家族修复,余 10 段是各文件
+book_tag 所在段本身)。
+
+那 1,290 段是唯一需要人工判断的部分(占全库 0.25%)。已抽查 book 110 para 993-999:
+旧表把 cs=5 的边界提前了 7 段,而该区间内除页码外无任何标记,`para5` 标记在 para 1000,
+新值(延续 cs=4 至 999)有据、旧值无据。
+
+### 遗留
+
+1. E2 章回绕 677 处未处理(本批不做,先让确定性规则跑干净)
+2. book 129 的 6,605 段现为 cs=0 占位,待用 csv 回填
+3. 20 段 `segment_without_markers` 已输出 cs=0 占位,待 LLM 归属
+4. book 11 para 1577 的 `para179-` 截断标记待人工定
+5. 上述 1,290 段边界差异待抽样复核

+ 23 - 24
api-v13/resources/views/pages/home.blade.php

@@ -24,7 +24,7 @@
 
             <div class="home-nav__actions">
                 <a class="home-nav__legacy"
-                    href="{{ config('mint.server.dashboard_base_path') }}"
+                    href="{{ config('mint.server.dashboard_base_path') }}/"
                     target="_blank"
                     rel="noopener">
                     {{ __('home.legacy') }}
@@ -45,17 +45,16 @@
     {{-- 四张分流卡片,2×2 网格 --}}
     <div class="home-grid">
         @foreach ($cards as $card)
-            <x-home.card
-                :slug="$card['slug']"
-                :eyebrow="$card['eyebrow']"
-                :title="$card['title']"
-                :lead="$card['lead']"
-                :icon="$card['icon']"
-                :tint="$card['tint']"
-                :href="$card['href']"
-                :cta="$card['cta']"
-                :available="$card['available']"
-            />
+        <x-home.card
+            :slug="$card['slug']"
+            :eyebrow="$card['eyebrow']"
+            :title="$card['title']"
+            :lead="$card['lead']"
+            :icon="$card['icon']"
+            :tint="$card['tint']"
+            :href="$card['href']"
+            :cta="$card['cta']"
+            :available="$card['available']" />
         @endforeach
     </div>
 
@@ -64,18 +63,18 @@
         <div class="home-footer__inner">
             <span class="home-footer__item">© {{ now()->year }} WikiPāli</span>
             @if (!empty(config('mint.app.icp_code')))
-                <span class="home-footer__item">
-                    <span>ICP备案号:</span>
-                    <a href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer">{{ config('mint.app.icp_code') }}</a>
-                </span>
-                <span class="home-footer__item">
-                    <img src="{{ asset('assets/images/logo_mps.png') }}" alt="公安备案" class="home-footer__icon" />
-                    @if (empty(config('mint.app.mps_code')))
-                        <span>滇公网安备[审批中]号</span>
-                    @else
-                        <span>{{ config('mint.app.mps_code') }}</span>
-                    @endif
-                </span>
+            <span class="home-footer__item">
+                <span>ICP备案号:</span>
+                <a href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer">{{ config('mint.app.icp_code') }}</a>
+            </span>
+            <span class="home-footer__item">
+                <img src="{{ asset('assets/images/logo_mps.png') }}" alt="公安备案" class="home-footer__icon" />
+                @if (empty(config('mint.app.mps_code')))
+                <span>滇公网安备[审批中]号</span>
+                @else
+                <span>{{ config('mint.app.mps_code') }}</span>
+                @endif
+            </span>
             @endif
         </div>
     </footer>

+ 3 - 0
api-v13/scripts/related/.gitignore

@@ -0,0 +1,3 @@
+report/
+data/cs6_para.csv
+data/backup/

+ 300 - 0
api-v13/scripts/related/align_chapters.php

@@ -0,0 +1,300 @@
+<?php
+
+/**
+ * 章回绕的跨侧对齐:给每个 book_name 的每个「cs 重置段」定一个章号,两侧用同一套章号。
+ *
+ * 为什么不能各干各的:book 143(mūla kn10)有 42 个章标记,book 107(义注 kn10)一个
+ * 都没有。单方面按标记加后缀,会让 kn10_1 只出现在 mūla 侧、义注侧还叫 kn10,
+ * 两侧的配对全部打断——比不加后缀更糟。所以必须先对齐,再一起加。
+ *
+ * 对齐靠什么:注释侧的 cs 值就是被注书的段落号,所以两侧每一章的 max(cs) 应当接近
+ * (实测 kn10 前 11 章:663/657、140/134、188/184、113/108…,注释侧略低,因为它不注
+ * 每章最后几段)。但段数常对不上(kn10 mūla 42 段 vs 义注 34 段,中间还夹着 max=2 的
+ * 碎段),所以按序号硬配会漂,得做带空位的序列比对。
+ *
+ * 用法:
+ *   php scripts/related/align_chapters.php --table=related_paragraphs_v1
+ *   php scripts/related/align_chapters.php --table=related_paragraphs_v1 --name=kn10 --verbose
+ */
+
+require __DIR__.'/lib/boot.php';
+
+use Illuminate\Support\Facades\DB;
+
+[$options] = rp_args($argv);
+$table = rp_assert_table($options['table'] ?? 'related_paragraphs_v1');
+$onlyName = $options['name'] ?? null;
+$verbose = isset($options['verbose']);
+/** 对齐得分低于此值的段判为不可信,不加后缀,转人工/LLM。 */
+$minConfidence = (float) ($options['min-confidence'] ?? 0.6);
+
+$log = new RpLogger('align_chapters', rp_report_dir());
+$log->info("被对齐表:{$table}");
+$log->info("置信度阈值:{$minConfidence}");
+
+$config = rp_json_read(__DIR__.'/book_config.json');
+
+/** 某 VRI 文件在注释链条中的位置。同一文件可能多种,取首个 level-1 的判定。 */
+$sideOf = function (int $book) use ($config): string {
+    $types = $config['books'][(string) $book]['file_types'] ?? [];
+
+    return $types[0]['type'] ?? 'unknown';
+};
+
+// ── 找出所有有回绕的 book_name ────────────────────────────────────────────
+$names = DB::select("
+    with s as (
+        select book_name, book, para, cs_para,
+               lag(cs_para) over (partition by book, book_name order by para, cs_para) as prev
+        from {$table} where cs_para > 0 and book_name <> ''
+    )
+    select book_name, count(*) as wraps
+    from s where cs_para = 1 and prev > 1
+    group by book_name order by count(*) desc
+");
+if ($onlyName) {
+    $names = array_values(array_filter($names, fn ($n) => $n->book_name === $onlyName));
+}
+$log->info('有回绕的 book_name:'.count($names).' 个');
+
+/**
+ * 把一个 (book_name, book) 的行切成「cs 重置段」。
+ *
+ * @return list<array{seg:int, book:int, para_from:int, para_to:int, cs_min:int, cs_max:int, rows:int}>
+ */
+$segmentsOf = function (string $name, int $book) use ($table): array {
+    $rows = DB::select("
+        select para, min(cs_para) as cs_min, max(cs_para) as cs_max
+        from {$table} where book_name = ? and book = ? and cs_para > 0
+        group by para order by para
+    ", [$name, $book]);
+
+    $segments = [];
+    $current = null;
+    $prevMin = null;
+    foreach ($rows as $r) {
+        // 按段落的 cs_min 判重置。用 cs_max 会被区间展开骗到:连续的 para1-2 段落
+        // 每段 cs 都是 {1,2},看 max 就成了 …2→1 的连环「重置」。
+        $isReset = $prevMin !== null && (int) $r->cs_min === 1 && $prevMin > 1;
+        if ($current === null || $isReset) {
+            if ($current !== null) {
+                $segments[] = $current;
+            }
+            $current = [
+                'seg' => count($segments), 'book' => $book,
+                'para_from' => (int) $r->para, 'para_to' => (int) $r->para,
+                'cs_min' => (int) $r->cs_min, 'cs_max' => (int) $r->cs_max, 'rows' => 0,
+            ];
+        }
+        $current['para_to'] = (int) $r->para;
+        $current['cs_max'] = max($current['cs_max'], (int) $r->cs_max);
+        $current['cs_min'] = min($current['cs_min'], (int) $r->cs_min);
+        $current['rows']++;
+        $prevMin = (int) $r->cs_min;
+    }
+    if ($current !== null) {
+        $segments[] = $current;
+    }
+
+    return $segments;
+};
+
+/** 两段 max(cs) 的接近程度,值域约 [-2, 2]。注释侧本就略低于 mūla,故不对称惩罚。 */
+$score = function (int $refMax, int $queryMax): float {
+    $scale = max($refMax, $queryMax, 1);
+    $diff = abs($refMax - $queryMax) / $scale;
+    $s = 2.0 - 4.0 * $diff;
+    if ($queryMax > $refMax) {
+        $s -= 0.5;   // 注释侧超过被注书的章长,不合体例,压一压
+    }
+
+    return $s;
+};
+
+/**
+ * 带空位的序列比对(Needleman–Wunsch)。
+ * 返回 query 段下标 → ref 段下标;对不上的段返回 null,由调用方并入前一章。
+ *
+ * @return array<int, ?int>
+ */
+$align = function (array $refMax, array $queryMax) use ($score): array {
+    $m = count($refMax);
+    $n = count($queryMax);
+    $gapRef = -0.6;      // 跳过一章参考段:该章没有注释,常见
+    $gapQuery = -1.2;    // 跳过一个查询段:多半是碎段,代价高些
+
+    $dp = array_fill(0, $m + 1, array_fill(0, $n + 1, 0.0));
+    $from = array_fill(0, $m + 1, array_fill(0, $n + 1, ''));
+    for ($i = 1; $i <= $m; $i++) {
+        $dp[$i][0] = $dp[$i - 1][0] + $gapRef;
+        $from[$i][0] = 'up';
+    }
+    for ($j = 1; $j <= $n; $j++) {
+        $dp[0][$j] = $dp[0][$j - 1] + $gapQuery;
+        $from[0][$j] = 'left';
+    }
+    for ($i = 1; $i <= $m; $i++) {
+        for ($j = 1; $j <= $n; $j++) {
+            $diag = $dp[$i - 1][$j - 1] + $score($refMax[$i - 1], $queryMax[$j - 1]);
+            $up = $dp[$i - 1][$j] + $gapRef;
+            $left = $dp[$i][$j - 1] + $gapQuery;
+            $best = max($diag, $up, $left);
+            $dp[$i][$j] = $best;
+            $from[$i][$j] = $best === $diag ? 'diag' : ($best === $up ? 'up' : 'left');
+        }
+    }
+
+    $map = [];
+    $i = $m;
+    $j = $n;
+    while ($i > 0 || $j > 0) {
+        $dir = $from[$i][$j];
+        if ($dir === 'diag') {
+            $map[$j - 1] = $i - 1;
+            $i--;
+            $j--;
+        } elseif ($dir === 'up') {
+            $i--;
+        } else {
+            $map[$j - 1] = null;
+            $j--;
+        }
+    }
+    ksort($map);
+
+    return $map;
+};
+
+$result = [];
+foreach ($names as $nameRow) {
+    $name = $nameRow->book_name;
+    $books = array_map(
+        fn ($r) => (int) $r->book,
+        DB::select("select distinct book from {$table} where book_name = ? order by book", [$name])
+    );
+
+    // 按侧分组,mūla 侧作参考;没有 mūla 侧就用行数最多的那一侧
+    $bySide = [];
+    foreach ($books as $book) {
+        $bySide[$sideOf($book)][] = $book;
+    }
+    $refSide = isset($bySide['mula']) ? 'mula' : array_key_first($bySide);
+    if ($refSide === null) {
+        $log->unresolved('chapter_align_no_side', ['book_name' => $name]);
+
+        continue;
+    }
+
+    // 参考侧的段序列(跨文件按 book 顺序拼接)即章号定义
+    $refSegments = [];
+    foreach ($bySide[$refSide] as $book) {
+        foreach ($segmentsOf($name, $book) as $s) {
+            $refSegments[] = $s;
+        }
+    }
+    if ($refSegments === []) {
+        continue;
+    }
+    $refMax = array_column($refSegments, 'cs_max');
+
+    $entry = [
+        'book_name' => $name,
+        'wraps' => (int) $nameRow->wraps,
+        'ref_side' => $refSide,
+        'ref_books' => $bySide[$refSide],
+        'chapters' => count($refSegments),
+        'assignments' => [],
+        'unaligned' => 0,
+        'confidence' => 1.0,
+    ];
+
+    $alignedTotal = 0;
+    $segTotal = 0;
+
+    foreach ($bySide as $side => $sideBooks) {
+        foreach ($sideBooks as $book) {
+            $segments = $segmentsOf($name, $book);
+            if ($segments === []) {
+                continue;
+            }
+
+            if ($side === $refSide) {
+                // 参考侧:段号即章号,但要按跨文件拼接后的全局下标
+                $offset = 0;
+                foreach ($bySide[$refSide] as $b) {
+                    if ($b === $book) {
+                        break;
+                    }
+                    $offset += count($segmentsOf($name, $b));
+                }
+                $map = [];
+                foreach (array_keys($segments) as $k) {
+                    $map[$k] = $offset + $k;
+                }
+            } else {
+                $map = $align($refMax, array_column($segments, 'cs_max'));
+            }
+
+            $lastChapter = null;
+            foreach ($segments as $k => $seg) {
+                $chapter = $map[$k] ?? null;
+                $segTotal++;
+                if ($chapter === null) {
+                    // 对不上的碎段并入前一章:它多半是注释侧的一次多余重置
+                    $chapter = $lastChapter;
+                    $entry['unaligned']++;
+                    $log->count('segment_merged_into_previous');
+                } else {
+                    $alignedTotal++;
+                    $lastChapter = $chapter;
+                }
+                if ($chapter === null) {
+                    $log->unresolved('chapter_align_orphan_segment', [
+                        'book_name' => $name, 'book' => $book,
+                        'para_from' => $seg['para_from'], 'para_to' => $seg['para_to'],
+                        'cs_max' => $seg['cs_max'],
+                    ]);
+
+                    continue;
+                }
+                $entry['assignments'][] = [
+                    'book' => $book, 'side' => $side,
+                    'para_from' => $seg['para_from'], 'para_to' => $seg['para_to'],
+                    'cs_max' => $seg['cs_max'],
+                    'chapter' => $chapter + 1,
+                    'ref_cs_max' => $refSegments[$chapter]['cs_max'],
+                ];
+            }
+        }
+    }
+
+    $entry['confidence'] = $segTotal > 0 ? round($alignedTotal / $segTotal, 3) : 0.0;
+    $entry['applicable'] = $entry['confidence'] >= $minConfidence && $entry['chapters'] > 1;
+    $result[$name] = $entry;
+
+    $log->info(sprintf('%-8s 章数 %-3d 段数 %-4d 对上 %-4d 置信 %.3f %s',
+        $name, $entry['chapters'], $segTotal, $alignedTotal, $entry['confidence'],
+        $entry['applicable'] ? '' : '← 置信不足,不加后缀'));
+
+    if ($verbose) {
+        foreach ($entry['assignments'] as $a) {
+            $log->info(sprintf('    book %-3d %-10s para %-6d-%-6d cs_max %-5d → 章 %-3d (参考 %d)',
+                $a['book'], $a['side'], $a['para_from'], $a['para_to'], $a['cs_max'],
+                $a['chapter'], $a['ref_cs_max']));
+        }
+    }
+}
+
+$applicable = array_filter($result, fn ($e) => $e['applicable']);
+$log->info(sprintf('可加后缀的 book_name:%d / %d', count($applicable), count($result)));
+
+$path = rp_report_dir().'/chapter_alignment.json';
+rp_json_write($path, [
+    'generated_at' => date('c'),
+    'table' => $table,
+    'min_confidence' => $minConfidence,
+    'names' => $result,
+]);
+$log->info("对齐表:{$path}");
+
+$log->finish();

+ 151 - 0
api-v13/scripts/related/apply_chapters.php

@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * 按 align_chapters.php 的对齐结果给 book_name 加章后缀,产出下一版表。
+ *
+ * 加后缀是为了让 (book_name, cs_para) 这个配对键重新变得唯一:回绕使同一个 key 在
+ * 几十个章里重复,index() 一次查询就把几十章的段落全捞回来。后缀必须两侧同时加、
+ * 用同一套章号,只给一侧加会把配对整个打断——所以只处理 align 判定为可信的 book_name,
+ * 其余原样留给补丁/LLM。
+ *
+ * 光靠 align 的置信度不够。实测 vin8 的 books 200/201 都覆盖 cs 1..3183(是并列的两侧,
+ * 不是上下册),却被当成顺序拼接标成了「第 1 章 / 第 2 章」,该名的跨文件配对 100% 报废;
+ * dict 的 books 9↔21 同理。侧别判定要做到永不出错是不现实的,所以每个 book_name 改名
+ * 前后都实测一次跨文件配对段数,掉了就整名回滚——指标说了算,不是规则说了算。
+ *
+ * 用法:
+ *   php scripts/related/apply_chapters.php --from=related_paragraphs_v1 --to=related_paragraphs_v2
+ */
+
+require __DIR__.'/lib/boot.php';
+
+use Illuminate\Support\Facades\DB;
+
+[$options] = rp_args($argv);
+$from = rp_assert_table($options['from'] ?? 'related_paragraphs_v1');
+$to = rp_assert_table($options['to'] ?? 'related_paragraphs_v2');
+
+$log = new RpLogger('apply_chapters', rp_report_dir());
+$log->info("源表:{$from}");
+$log->info("目标表:{$to}");
+
+$alignment = rp_json_read(rp_report_dir().'/chapter_alignment.json');
+if ($alignment['table'] !== $from) {
+    $log->error("对齐表是针对 {$alignment['table']} 算的,与源表 {$from} 不符,先重跑 align_chapters.php");
+    $log->finish();
+    exit(1);
+}
+
+$log->info('复制源表…');
+DB::statement("drop table if exists {$to}");
+DB::statement("create table {$to} as select * from {$from}");
+DB::statement("create index on {$to} (book, para)");
+$log->info('行数:'.DB::table($to)->count());
+
+$applied = 0;
+$rowsChanged = 0;
+$reverted = 0;
+
+/**
+ * 某个 book_name(含其所有章后缀变体)下,有多少 (book, para) 通过**可信的**配对键
+ * 跨文件命中。可信 = 该键在任何单本书里命中的段落数不超过 $maxParas。
+ *
+ * 为什么要排除膨胀键:回绕让 kn10:1 这样的键在 56 个章里重复,一次查询捞回上千段,
+ * 那不是配对是噪声。直接数「配对段数」会把这些噪声算成资产——实测 kn10 加后缀后该数
+ * 从 23757 掉到 6279,看着像灾难,其实掉的几乎全是 book 144 章 43-56 的虚假配对
+ * (那几章本来就没有义注,是靠撞在一起的 kn10:1 假装配上的)。只数非膨胀键,
+ * 加对了后缀这个数会涨(膨胀键裂成一批干净键),加错了才会掉。
+ */
+$pairedParagraphs = function (string $table, string $baseName, int $maxParas = 10): int {
+    $pattern = str_replace(['_', '%'], ['\\_', '\\%'], $baseName);
+
+    return (int) DB::selectOne("
+        with mine as (
+            select book, para, book_name, cs_para
+            from {$table}
+            where cs_para > 0 and (book_name = ? or book_name like ?)
+        ),
+        per_book as (
+            select book_name, cs_para, book, count(distinct para) as paras
+            from mine group by book_name, cs_para, book
+        ),
+        k as (
+            select book_name, cs_para from per_book
+            group by book_name, cs_para
+            having count(*) >= 2 and max(paras) <= ?
+        )
+        select count(distinct (m.book, m.para))::int as n
+        from mine m join k on k.book_name = m.book_name and k.cs_para = m.cs_para
+    ", [$baseName, $pattern.'\_%', $maxParas])->n;
+};
+
+foreach ($alignment['names'] as $name => $entry) {
+    if (! $entry['applicable']) {
+        $log->count('name.skipped_low_confidence');
+        $log->unresolved('chapter_align_low_confidence', [
+            'book_name' => $name, 'confidence' => $entry['confidence'],
+            'chapters' => $entry['chapters'], 'wraps' => $entry['wraps'],
+            'detail' => '跨侧对齐置信不足,未加后缀,待补丁或 LLM 判定',
+        ]);
+
+        continue;
+    }
+    if (str_ends_with($name, '_A')) {
+        // 序言名不分章:它本就不对应正文段落,加后缀没有意义
+        $log->count('name.skipped_prologue');
+
+        continue;
+    }
+
+    $before = $pairedParagraphs($to, $name);
+
+    $changed = 0;
+    foreach ($entry['assignments'] as $a) {
+        $changed += DB::table($to)
+            ->where('book', $a['book'])
+            ->where('book_name', $name)
+            ->whereBetween('para', [$a['para_from'], $a['para_to']])
+            ->update(['book_name' => $name.'_'.$a['chapter']]);
+    }
+
+    $after = $pairedParagraphs($to, $name);
+
+    // 加对了后缀这个数应当上升或持平;下跌就是把真配对拆散了,整名回滚。
+    $tolerance = (int) max(5, $before * 0.02);
+    if ($after < $before - $tolerance) {
+        foreach ($entry['assignments'] as $a) {
+            DB::table($to)
+                ->where('book', $a['book'])
+                ->where('book_name', $name.'_'.$a['chapter'])
+                ->whereBetween('para', [$a['para_from'], $a['para_to']])
+                ->update(['book_name' => $name]);
+        }
+        $reverted++;
+        $log->warn(sprintf('%-8s 回滚:可信配对 %d → %d 段,跌幅超容差 %d', $name, $before, $after, $tolerance));
+        $log->unresolved('chapter_suffix_reverted', [
+            'book_name' => $name, 'paired_before' => $before, 'paired_after' => $after,
+            'chapters' => $entry['chapters'], 'confidence' => $entry['confidence'],
+            'detail' => '加章后缀后可信配对大幅下跌,已回滚;多半是并列的两本书被当成了顺序拼接,待补丁或 LLM 判定',
+        ]);
+        $log->count('name.reverted');
+
+        continue;
+    }
+
+    $applied++;
+    $rowsChanged += $changed;
+    $log->info(sprintf('%-8s %d 章,改名 %-6d 行,可信配对 %d → %d 段',
+        $name, $entry['chapters'], $changed, $before, $after));
+    $log->count('rows.renamed', $changed);
+}
+
+$log->info("加后缀的 book_name:{$applied} 个,共改名 {$rowsChanged} 行;因配对下跌回滚 {$reverted} 个");
+
+$log->info('建索引中…');
+DB::statement("create index on {$to} (book)");
+DB::statement("create index on {$to} (para)");
+DB::statement("create index on {$to} (book_name)");
+DB::statement("create index on {$to} (book_name, cs_para)");
+DB::statement("create index on {$to} (book, para, cs_para)");
+
+$log->finish();

+ 103 - 0
api-v13/scripts/related/backup.php

@@ -0,0 +1,103 @@
+<?php
+
+/**
+ * 备份生产 related_paragraphs:库内快照表 + 库外 csv 存档 + 清单。
+ *
+ * 用法:
+ *   php scripts/related/backup.php                 # 表名自动带当天日期
+ *   php scripts/related/backup.php --tag=20260909  # 指定后缀
+ *   php scripts/related/backup.php --force         # 快照表已存在时覆盖
+ *
+ * 这是整个重建工程唯一允许写生产库的脚本,而且只做 CREATE TABLE AS,不改原表。
+ */
+
+require __DIR__.'/lib/boot.php';
+
+use Illuminate\Support\Facades\DB;
+
+[$options] = rp_args($argv);
+
+$source = 'related_paragraphs';
+$tag = $options['tag'] ?? date('Ymd');
+$snapshot = rp_assert_table("related_paragraphs_bak_$tag");
+$log = new RpLogger('backup', rp_report_dir());
+
+$log->info("源表:$source");
+$log->info("快照表:$snapshot");
+
+$sourceCount = DB::table($source)->count();
+$log->info("源表行数:$sourceCount");
+if ($sourceCount === 0) {
+    $log->error('源表为空,拒绝备份——这几乎一定是连错库了。');
+    $log->finish();
+    exit(1);
+}
+
+$exists = DB::selectOne('select to_regclass(?) as reg', [$snapshot])->reg !== null;
+if ($exists) {
+    if (! isset($options['force'])) {
+        $existing = DB::table($snapshot)->count();
+        $log->error("快照表已存在($existing 行)。要覆盖请加 --force。");
+        $log->finish();
+        exit(1);
+    }
+    $log->warn('快照表已存在,--force 生效,先 DROP。');
+    DB::statement("drop table $snapshot");
+}
+
+$log->info('建快照表中…');
+DB::statement("create table $snapshot as select * from $source");
+$snapshotCount = DB::table($snapshot)->count();
+$log->info("快照表行数:$snapshotCount");
+
+if ($snapshotCount !== $sourceCount) {
+    $log->error("行数不一致:源 $sourceCount vs 快照 $snapshotCount");
+    $log->finish();
+    exit(1);
+}
+
+// 快照表只用于比对,建最小索引即可(按 book/para 定位、按 book_name+cs_para 配对)。
+$log->info('建索引中…');
+DB::statement("create index on $snapshot (book, para)");
+DB::statement("create index on $snapshot (book_name, cs_para)");
+
+// 库外存档:库没了也能拿回数据。
+$archiveDir = rp_data_dir().'/backup';
+if (! is_dir($archiveDir)) {
+    mkdir($archiveDir, 0o755, true);
+}
+$csvPath = "$archiveDir/$snapshot.csv";
+$log->info("导出 csv:$csvPath");
+
+$handle = fopen($csvPath, 'w');
+fputcsv($handle, ['book', 'para', 'book_id', 'cs_para', 'book_name']);
+$rows = 0;
+DB::table($source)->orderBy('book')->orderBy('para')->orderBy('cs_para')->orderBy('id')
+    ->select('book', 'para', 'book_id', 'cs_para', 'book_name')
+    ->chunk(20000, function ($chunk) use ($handle, &$rows, $log) {
+        foreach ($chunk as $row) {
+            fputcsv($handle, [$row->book, $row->para, $row->book_id, $row->cs_para, $row->book_name]);
+            $rows++;
+        }
+        if ($rows % 200000 === 0) {
+            $log->info("  已导出 $rows 行");
+        }
+    });
+fclose($handle);
+$log->info("csv 行数:$rows");
+
+$manifest = [
+    'created_at' => date('c'),
+    'database' => DB::connection()->getDatabaseName(),
+    'source_table' => $source,
+    'snapshot_table' => $snapshot,
+    'rows' => $sourceCount,
+    'csv_path' => $csvPath,
+    'csv_sha256' => hash_file('sha256', $csvPath),
+    'csv_bytes' => filesize($csvPath),
+];
+rp_json_write("$archiveDir/$snapshot.manifest.json", $manifest);
+$log->info("清单:$archiveDir/$snapshot.manifest.json");
+$log->info('sha256: '.$manifest['csv_sha256']);
+
+$log->finish();

+ 3049 - 0
api-v13/scripts/related/baseline.json

@@ -0,0 +1,3049 @@
+{
+    "frozen_at": "2026-09-09T15:17:04+00:00",
+    "source_table": "related_paragraphs_bak_20260909",
+    "database": "visuddhinanda_20260311",
+    "purpose": "旧表的实测基线。clean_books 是零错误信号的书,重建后这些书一本都不许掉出来;detectors 是各探测器的旧表读数,用于回答「错误是否减少」。",
+    "clean_books": [
+        2,
+        3,
+        4,
+        5,
+        6,
+        7,
+        8,
+        10,
+        12,
+        13,
+        14,
+        15,
+        16,
+        17,
+        18,
+        19,
+        20,
+        22,
+        23,
+        24,
+        25,
+        26,
+        27,
+        28,
+        29,
+        30,
+        31,
+        32,
+        33,
+        34,
+        35,
+        36,
+        37,
+        38,
+        39,
+        40,
+        41,
+        42,
+        43,
+        44,
+        45,
+        46,
+        47,
+        48,
+        49,
+        50,
+        51,
+        52,
+        53,
+        54,
+        55,
+        56,
+        57,
+        58,
+        59,
+        60,
+        61,
+        62,
+        63,
+        64,
+        65,
+        66,
+        67,
+        68,
+        74,
+        75,
+        77,
+        82,
+        83,
+        84,
+        85,
+        86,
+        87,
+        88,
+        89,
+        90,
+        91,
+        92,
+        93,
+        94,
+        95,
+        99,
+        101,
+        102,
+        105,
+        106,
+        110,
+        115,
+        117,
+        118,
+        129,
+        131,
+        133,
+        134,
+        135,
+        136,
+        141,
+        149,
+        155,
+        156,
+        157,
+        158,
+        159,
+        160,
+        161,
+        162,
+        163,
+        164,
+        165,
+        166,
+        167,
+        168,
+        169,
+        170,
+        171,
+        177,
+        180,
+        181,
+        187,
+        188,
+        196,
+        197,
+        198,
+        204,
+        212,
+        213,
+        214,
+        215,
+        216,
+        217
+    ],
+    "detectors": {
+        "E1_cs_garbage": {
+            "rows": 1242,
+            "books": 24
+        },
+        "E2_chapter_wrap": {
+            "wrap_points": 395,
+            "books": 64
+        },
+        "E3_kn10_missing": {
+            "kn10_rows": 0,
+            "kn10_a_rows": 16926,
+            "kn10_b_rows": 12489
+        },
+        "E4_empty_book_name": {
+            "rows": 112965,
+            "books": 60
+        },
+        "E5_book_id_zero": {
+            "rows": 354,
+            "books": 201
+        },
+        "E6_coverage_gap": {
+            "segments": 3466,
+            "books": 45
+        },
+        "E7_abbrev_interval_lost": {
+            "marker_segments": 1519,
+            "books": 44,
+            "segments_without_rows": 1292,
+            "segments_with_garbage": 225
+        },
+        "E8_flat_pollution": {
+            "groups": 1,
+            "rows": 1499
+        },
+        "E9_no_marker_books": {
+            "books": 16,
+            "book_list": [
+                8,
+                15,
+                16,
+                25,
+                37,
+                39,
+                40,
+                41,
+                42,
+                44,
+                45,
+                47,
+                48,
+                56,
+                61,
+                129
+            ]
+        },
+        "E10_selfmade_names": {
+            "nk_rows": 986,
+            "subo_rows": 4258,
+            "dict_rows": 7041
+        },
+        "D_continuity": {
+            "broken_groups": 9026,
+            "books": 69,
+            "extra_segments": 30204
+        },
+        "D_mirror_alignment": {
+            "deviations": 0,
+            "detail": []
+        },
+        "D_interval_expansion": {
+            "interval_markers": 3615,
+            "mismatched_markers": 1611,
+            "empty_markers": 1611
+        },
+        "D_pairing": {
+            "keys": 41525,
+            "keys_cross_book": 27853,
+            "rows_paired": 432110,
+            "rows_paired_trusted": 177016,
+            "rows_total": 507795,
+            "mean_books_per_key": 2.079,
+            "keys_bloated": 6671,
+            "keys_bloated_bad": 1413,
+            "worst_paras_in_one_book": 1578
+        }
+    },
+    "scorecard": [
+        {
+            "book": 1,
+            "rows": 986,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 3,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 3
+        },
+        {
+            "book": 2,
+            "rows": 845,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 845,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 3,
+            "rows": 316,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 316,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 4,
+            "rows": 597,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 597,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 5,
+            "rows": 1025,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 1025,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 6,
+            "rows": 302,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 302,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 7,
+            "rows": 526,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 526,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 8,
+            "rows": 2458,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 2458,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 9,
+            "rows": 3109,
+            "names": 1,
+            "garbage_rows": 31,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 32
+        },
+        {
+            "book": 10,
+            "rows": 1320,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 11,
+            "rows": 2938,
+            "names": 1,
+            "garbage_rows": 96,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 2,
+            "gap_segments": 3,
+            "flat_groups": 0,
+            "signals": 101
+        },
+        {
+            "book": 12,
+            "rows": 847,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 847,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 13,
+            "rows": 3262,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 3262,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 14,
+            "rows": 3486,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 3486,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 15,
+            "rows": 2980,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 2980,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 16,
+            "rows": 3401,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 3401,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 17,
+            "rows": 4055,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 4055,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 18,
+            "rows": 1579,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 1579,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 19,
+            "rows": 2493,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 2493,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 20,
+            "rows": 318,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 318,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 21,
+            "rows": 3963,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 31,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 22,
+            "rows": 6144,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 6144,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 23,
+            "rows": 1498,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 24,
+            "rows": 442,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 25,
+            "rows": 249,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 249,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 26,
+            "rows": 1960,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 1960,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 27,
+            "rows": 1347,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 1347,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 28,
+            "rows": 243,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 243,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 29,
+            "rows": 1333,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 1333,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 30,
+            "rows": 1283,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 1283,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 31,
+            "rows": 876,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 876,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 32,
+            "rows": 266,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 266,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 33,
+            "rows": 545,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 545,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 34,
+            "rows": 552,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 552,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 35,
+            "rows": 1847,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 1847,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 36,
+            "rows": 2457,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 2457,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 37,
+            "rows": 433,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 433,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 38,
+            "rows": 2613,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 2613,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 39,
+            "rows": 349,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 349,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 40,
+            "rows": 688,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 688,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 41,
+            "rows": 1256,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 1256,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 42,
+            "rows": 907,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 907,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 43,
+            "rows": 1554,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 1554,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 44,
+            "rows": 296,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 296,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 45,
+            "rows": 503,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 503,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 46,
+            "rows": 1240,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 47,
+            "rows": 1640,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 1640,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 48,
+            "rows": 1095,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 1095,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 49,
+            "rows": 83,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 83,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 50,
+            "rows": 494,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 494,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 51,
+            "rows": 3057,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 3057,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 52,
+            "rows": 1703,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 1703,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 53,
+            "rows": 709,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 709,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 54,
+            "rows": 1642,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 1642,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 55,
+            "rows": 118,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 118,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 56,
+            "rows": 491,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 491,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 57,
+            "rows": 364,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 364,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 58,
+            "rows": 1649,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 1649,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 59,
+            "rows": 9907,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 9907,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 60,
+            "rows": 501,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 501,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 61,
+            "rows": 152,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 152,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 62,
+            "rows": 971,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 971,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 63,
+            "rows": 15944,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 15944,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 64,
+            "rows": 2240,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 65,
+            "rows": 2192,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 66,
+            "rows": 1613,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 67,
+            "rows": 1851,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 68,
+            "rows": 2701,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 2701,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 69,
+            "rows": 4241,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 16,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 16
+        },
+        {
+            "book": 70,
+            "rows": 6004,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 9,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 9
+        },
+        {
+            "book": 71,
+            "rows": 8046,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 20,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 20
+        },
+        {
+            "book": 72,
+            "rows": 5169,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 19,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 19
+        },
+        {
+            "book": 73,
+            "rows": 2436,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 2,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 2
+        },
+        {
+            "book": 74,
+            "rows": 3427,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 75,
+            "rows": 673,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 76,
+            "rows": 561,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 77,
+            "rows": 3460,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 78,
+            "rows": 4774,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 4,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 4
+        },
+        {
+            "book": 79,
+            "rows": 3859,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 2,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 2
+        },
+        {
+            "book": 80,
+            "rows": 5586,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 81,
+            "rows": 4301,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 4,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 4
+        },
+        {
+            "book": 82,
+            "rows": 3141,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 83,
+            "rows": 1889,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 84,
+            "rows": 957,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 85,
+            "rows": 485,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 86,
+            "rows": 1938,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 87,
+            "rows": 3439,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 88,
+            "rows": 16266,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 89,
+            "rows": 2377,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 1,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 90,
+            "rows": 14831,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 91,
+            "rows": 2604,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 92,
+            "rows": 1504,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 93,
+            "rows": 1074,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 94,
+            "rows": 1559,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 95,
+            "rows": 1910,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 96,
+            "rows": 3551,
+            "names": 1,
+            "garbage_rows": 3,
+            "no_book_id_rows": 3,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 2,
+            "gap_segments": 30,
+            "flat_groups": 0,
+            "signals": 36
+        },
+        {
+            "book": 97,
+            "rows": 2423,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 3,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 2,
+            "gap_segments": 9,
+            "flat_groups": 0,
+            "signals": 11
+        },
+        {
+            "book": 98,
+            "rows": 17589,
+            "names": 5,
+            "garbage_rows": 40,
+            "no_book_id_rows": 3,
+            "empty_name_rows": 0,
+            "wrap_points": 12,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 52
+        },
+        {
+            "book": 99,
+            "rows": 2078,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 100,
+            "rows": 3738,
+            "names": 3,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 2,
+            "gap_segments": 4,
+            "flat_groups": 0,
+            "signals": 6
+        },
+        {
+            "book": 101,
+            "rows": 1162,
+            "names": 3,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 102,
+            "rows": 950,
+            "names": 4,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 103,
+            "rows": 1976,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 1,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 104,
+            "rows": 1731,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 1,
+            "gap_segments": 4,
+            "flat_groups": 0,
+            "signals": 5
+        },
+        {
+            "book": 105,
+            "rows": 1131,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 106,
+            "rows": 4548,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 107,
+            "rows": 4096,
+            "names": 2,
+            "garbage_rows": 47,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 22,
+            "abbrev_lost": 22,
+            "gap_segments": 48,
+            "flat_groups": 0,
+            "signals": 139
+        },
+        {
+            "book": 108,
+            "rows": 4194,
+            "names": 1,
+            "garbage_rows": 80,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 25,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 105
+        },
+        {
+            "book": 109,
+            "rows": 2136,
+            "names": 1,
+            "garbage_rows": 80,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 2,
+            "abbrev_lost": 6,
+            "gap_segments": 10,
+            "flat_groups": 0,
+            "signals": 98
+        },
+        {
+            "book": 110,
+            "rows": 3117,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 3,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 111,
+            "rows": 2997,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 112,
+            "rows": 4892,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 3,
+            "empty_name_rows": 0,
+            "wrap_points": 5,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 5
+        },
+        {
+            "book": 113,
+            "rows": 7714,
+            "names": 1,
+            "garbage_rows": 21,
+            "no_book_id_rows": 3,
+            "empty_name_rows": 0,
+            "wrap_points": 5,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 26
+        },
+        {
+            "book": 114,
+            "rows": 5079,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 0,
+            "empty_name_rows": 0,
+            "wrap_points": 4,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 4
+        },
+        {
+            "book": 115,
+            "rows": 3688,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 116,
+            "rows": 1235,
+            "names": 1,
+            "garbage_rows": 26,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 4,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 30
+        },
+        {
+            "book": 117,
+            "rows": 6463,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 118,
+            "rows": 1833,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 119,
+            "rows": 765,
+            "names": 1,
+            "garbage_rows": 18,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 1,
+            "gap_segments": 2,
+            "flat_groups": 0,
+            "signals": 21
+        },
+        {
+            "book": 120,
+            "rows": 2718,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 3,
+            "abbrev_lost": 1,
+            "gap_segments": 6,
+            "flat_groups": 0,
+            "signals": 10
+        },
+        {
+            "book": 121,
+            "rows": 1096,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 122,
+            "rows": 5231,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 123,
+            "rows": 1688,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 124,
+            "rows": 1766,
+            "names": 1,
+            "garbage_rows": 7,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 7
+        },
+        {
+            "book": 125,
+            "rows": 2505,
+            "names": 1,
+            "garbage_rows": 1,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 114,
+            "gap_segments": 306,
+            "flat_groups": 0,
+            "signals": 421
+        },
+        {
+            "book": 126,
+            "rows": 3973,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 36,
+            "gap_segments": 87,
+            "flat_groups": 0,
+            "signals": 124
+        },
+        {
+            "book": 127,
+            "rows": 3281,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 46,
+            "gap_segments": 98,
+            "flat_groups": 0,
+            "signals": 145
+        },
+        {
+            "book": 128,
+            "rows": 5390,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 2,
+            "gap_segments": 22,
+            "flat_groups": 0,
+            "signals": 24
+        },
+        {
+            "book": 129,
+            "rows": 6605,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 130,
+            "rows": 2906,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 2,
+            "gap_segments": 6,
+            "flat_groups": 0,
+            "signals": 9
+        },
+        {
+            "book": 131,
+            "rows": 1369,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 132,
+            "rows": 1105,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 1,
+            "gap_segments": 2,
+            "flat_groups": 0,
+            "signals": 3
+        },
+        {
+            "book": 133,
+            "rows": 1480,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 134,
+            "rows": 1228,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 135,
+            "rows": 2850,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 136,
+            "rows": 1862,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 137,
+            "rows": 2840,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 9,
+            "gap_segments": 52,
+            "flat_groups": 0,
+            "signals": 61
+        },
+        {
+            "book": 138,
+            "rows": 2805,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 26,
+            "gap_segments": 218,
+            "flat_groups": 0,
+            "signals": 244
+        },
+        {
+            "book": 139,
+            "rows": 1349,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 32,
+            "gap_segments": 147,
+            "flat_groups": 0,
+            "signals": 179
+        },
+        {
+            "book": 140,
+            "rows": 944,
+            "names": 1,
+            "garbage_rows": 6,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 6,
+            "gap_segments": 18,
+            "flat_groups": 0,
+            "signals": 30
+        },
+        {
+            "book": 141,
+            "rows": 670,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 142,
+            "rows": 717,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 1,
+            "gap_segments": 1,
+            "flat_groups": 0,
+            "signals": 2
+        },
+        {
+            "book": 143,
+            "rows": 14646,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 41,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 41
+        },
+        {
+            "book": 144,
+            "rows": 10673,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 17,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 17
+        },
+        {
+            "book": 145,
+            "rows": 3282,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 28,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 28
+        },
+        {
+            "book": 146,
+            "rows": 1199,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 2,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 2
+        },
+        {
+            "book": 147,
+            "rows": 11528,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 5,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 5
+        },
+        {
+            "book": 148,
+            "rows": 10506,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 15,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 15
+        },
+        {
+            "book": 149,
+            "rows": 2927,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 150,
+            "rows": 2771,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 151,
+            "rows": 2044,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 3,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 3
+        },
+        {
+            "book": 152,
+            "rows": 2493,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 22,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 22
+        },
+        {
+            "book": 153,
+            "rows": 1624,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 154,
+            "rows": 276,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 6,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 6
+        },
+        {
+            "book": 155,
+            "rows": 1217,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 156,
+            "rows": 1394,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 157,
+            "rows": 965,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 158,
+            "rows": 1194,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 159,
+            "rows": 4051,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 160,
+            "rows": 3279,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 161,
+            "rows": 2655,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 162,
+            "rows": 4669,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 163,
+            "rows": 1785,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 164,
+            "rows": 1797,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 165,
+            "rows": 1690,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 166,
+            "rows": 1463,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 167,
+            "rows": 3498,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 168,
+            "rows": 1427,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 169,
+            "rows": 3165,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 170,
+            "rows": 2225,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 171,
+            "rows": 5900,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 172,
+            "rows": 1366,
+            "names": 1,
+            "garbage_rows": 7,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 2,
+            "abbrev_lost": 1,
+            "gap_segments": 4,
+            "flat_groups": 0,
+            "signals": 14
+        },
+        {
+            "book": 173,
+            "rows": 2315,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 7,
+            "gap_segments": 20,
+            "flat_groups": 0,
+            "signals": 28
+        },
+        {
+            "book": 174,
+            "rows": 10905,
+            "names": 5,
+            "garbage_rows": 39,
+            "no_book_id_rows": 3,
+            "empty_name_rows": 0,
+            "wrap_points": 10,
+            "abbrev_lost": 2,
+            "gap_segments": 6,
+            "flat_groups": 0,
+            "signals": 57
+        },
+        {
+            "book": 175,
+            "rows": 1275,
+            "names": 1,
+            "garbage_rows": 9,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 2,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 11
+        },
+        {
+            "book": 176,
+            "rows": 11901,
+            "names": 5,
+            "garbage_rows": 43,
+            "no_book_id_rows": 3,
+            "empty_name_rows": 0,
+            "wrap_points": 10,
+            "abbrev_lost": 0,
+            "gap_segments": 3,
+            "flat_groups": 0,
+            "signals": 56
+        },
+        {
+            "book": 177,
+            "rows": 15499,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 10695,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 178,
+            "rows": 1838,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 17,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 17
+        },
+        {
+            "book": 179,
+            "rows": 2403,
+            "names": 2,
+            "garbage_rows": 55,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 418,
+            "gap_segments": 805,
+            "flat_groups": 0,
+            "signals": 1279
+        },
+        {
+            "book": 180,
+            "rows": 2826,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 2826,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 181,
+            "rows": 1451,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 182,
+            "rows": 3328,
+            "names": 3,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 10,
+            "gap_segments": 46,
+            "flat_groups": 0,
+            "signals": 56
+        },
+        {
+            "book": 183,
+            "rows": 2094,
+            "names": 3,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 2,
+            "abbrev_lost": 10,
+            "gap_segments": 45,
+            "flat_groups": 0,
+            "signals": 57
+        },
+        {
+            "book": 184,
+            "rows": 6356,
+            "names": 4,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 1,
+            "gap_segments": 4,
+            "flat_groups": 0,
+            "signals": 5
+        },
+        {
+            "book": 185,
+            "rows": 1588,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 17,
+            "gap_segments": 54,
+            "flat_groups": 0,
+            "signals": 71
+        },
+        {
+            "book": 186,
+            "rows": 1509,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 1,
+            "gap_segments": 13,
+            "flat_groups": 0,
+            "signals": 14
+        },
+        {
+            "book": 187,
+            "rows": 1132,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 188,
+            "rows": 1781,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 189,
+            "rows": 1572,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 10,
+            "gap_segments": 37,
+            "flat_groups": 0,
+            "signals": 47
+        },
+        {
+            "book": 190,
+            "rows": 764,
+            "names": 1,
+            "garbage_rows": 1,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 2
+        },
+        {
+            "book": 191,
+            "rows": 1670,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 192,
+            "rows": 2397,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 2,
+            "abbrev_lost": 3,
+            "gap_segments": 3,
+            "flat_groups": 0,
+            "signals": 8
+        },
+        {
+            "book": 193,
+            "rows": 936,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 1,
+            "gap_segments": 3,
+            "flat_groups": 0,
+            "signals": 4
+        },
+        {
+            "book": 194,
+            "rows": 856,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 1,
+            "gap_segments": 2,
+            "flat_groups": 0,
+            "signals": 3
+        },
+        {
+            "book": 195,
+            "rows": 1709,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 196,
+            "rows": 1328,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 197,
+            "rows": 3276,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 198,
+            "rows": 1928,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 199,
+            "rows": 2837,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 7,
+            "gap_segments": 28,
+            "flat_groups": 0,
+            "signals": 35
+        },
+        {
+            "book": 200,
+            "rows": 13281,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 201,
+            "rows": 4584,
+            "names": 2,
+            "garbage_rows": 452,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 256,
+            "gap_segments": 566,
+            "flat_groups": 0,
+            "signals": 1275
+        },
+        {
+            "book": 202,
+            "rows": 2989,
+            "names": 4,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 3,
+            "abbrev_lost": 10,
+            "gap_segments": 55,
+            "flat_groups": 0,
+            "signals": 68
+        },
+        {
+            "book": 203,
+            "rows": 3404,
+            "names": 2,
+            "garbage_rows": 81,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 84,
+            "gap_segments": 294,
+            "flat_groups": 0,
+            "signals": 460
+        },
+        {
+            "book": 204,
+            "rows": 1659,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 205,
+            "rows": 1926,
+            "names": 1,
+            "garbage_rows": 5,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 2,
+            "abbrev_lost": 3,
+            "gap_segments": 4,
+            "flat_groups": 0,
+            "signals": 14
+        },
+        {
+            "book": 206,
+            "rows": 2862,
+            "names": 4,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 3,
+            "abbrev_lost": 4,
+            "gap_segments": 10,
+            "flat_groups": 0,
+            "signals": 17
+        },
+        {
+            "book": 207,
+            "rows": 2713,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 12,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 1,
+            "signals": 13
+        },
+        {
+            "book": 208,
+            "rows": 1673,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 1
+        },
+        {
+            "book": 209,
+            "rows": 2562,
+            "names": 5,
+            "garbage_rows": 70,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 1,
+            "abbrev_lost": 85,
+            "gap_segments": 255,
+            "flat_groups": 0,
+            "signals": 411
+        },
+        {
+            "book": 210,
+            "rows": 3901,
+            "names": 5,
+            "garbage_rows": 24,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 4,
+            "abbrev_lost": 46,
+            "gap_segments": 132,
+            "flat_groups": 0,
+            "signals": 206
+        },
+        {
+            "book": 211,
+            "rows": 2413,
+            "names": 2,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 2,
+            "abbrev_lost": 1,
+            "gap_segments": 1,
+            "flat_groups": 0,
+            "signals": 4
+        },
+        {
+            "book": 212,
+            "rows": 2517,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 1,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 213,
+            "rows": 2477,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 214,
+            "rows": 3690,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 215,
+            "rows": 2759,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 216,
+            "rows": 2821,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        },
+        {
+            "book": 217,
+            "rows": 3645,
+            "names": 1,
+            "garbage_rows": 0,
+            "no_book_id_rows": 2,
+            "empty_name_rows": 0,
+            "wrap_points": 0,
+            "abbrev_lost": 0,
+            "gap_segments": 0,
+            "flat_groups": 0,
+            "signals": 0
+        }
+    ]
+}

+ 6374 - 0
api-v13/scripts/related/book_config.json

@@ -0,0 +1,6374 @@
+{
+    "generated_at": "2026-09-09T15:01:15+00:00",
+    "baseline_table": "related_paragraphs_bak_20260909",
+    "purpose": "每本 VRI 文件的类型与书名分段。导出器优先用 wbw 现场证据,此表只在文件本身没有书名信息时兜底。overrides 是人工修正区,重跑不覆盖。",
+    "books": {
+        "1": {
+            "max_paragraph": 981,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 1,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "namakkāra",
+                        "añña",
+                        "buddha-vandanā ganthasaṅgaha",
+                        "namakkārapāḷi"
+                    ]
+                },
+                {
+                    "book_id": 2,
+                    "from_paragraph": 159,
+                    "type": "tika",
+                    "tags": [
+                        "namakkāraṭīkā",
+                        "buddha-vandanā ganthasaṅgaha",
+                        "añña",
+                        "ṭīkā",
+                        "namakkāra"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 981,
+                    "book_name": "NK"
+                }
+            ]
+        },
+        "2": {
+            "max_paragraph": 845,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 3,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "buddha-vandanā ganthasaṅgaha",
+                        "mahāpaṇāmapāṭha"
+                    ]
+                },
+                {
+                    "book_id": 4,
+                    "from_paragraph": 428,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "mahāpaṇāmapāṭha",
+                        "buddha-vandanā ganthasaṅgaha"
+                    ]
+                },
+                {
+                    "book_id": 5,
+                    "from_paragraph": 479,
+                    "type": "unknown",
+                    "tags": [
+                        "mahāpaṇāmapāṭha",
+                        "buddha-vandanā ganthasaṅgaha",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 845,
+                    "book_name": ""
+                }
+            ]
+        },
+        "3": {
+            "max_paragraph": 316,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 6,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "lakkhaṇāto",
+                        "buddha-vandanā ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 316,
+                    "book_name": ""
+                }
+            ]
+        },
+        "4": {
+            "max_paragraph": 597,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 7,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "suttavandanā",
+                        "buddha-vandanā ganthasaṅgaha",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 597,
+                    "book_name": ""
+                }
+            ]
+        },
+        "5": {
+            "max_paragraph": 1025,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 8,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "jinālaṅkāra",
+                        "añña",
+                        "buddha-vandanā ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1025,
+                    "book_name": ""
+                }
+            ]
+        },
+        "6": {
+            "max_paragraph": 302,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 9,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "buddha-vandanā ganthasaṅgaha",
+                        "kamalāñjali"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 302,
+                    "book_name": ""
+                }
+            ]
+        },
+        "7": {
+            "max_paragraph": 526,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 10,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "pajjamadhu",
+                        "buddha-vandanā ganthasaṅgaha",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 526,
+                    "book_name": ""
+                }
+            ]
+        },
+        "8": {
+            "max_paragraph": 2458,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 11,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "buddha-vandanā ganthasaṅgaha",
+                        "buddhaguṇagāthāvalī",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2458,
+                    "book_name": ""
+                }
+            ]
+        },
+        "9": {
+            "max_paragraph": 2714,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 12,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "abhidhānappadīpikāṭīkā",
+                        "nirutti",
+                        "abhidhānappadīpikā",
+                        "byākaraṇa ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2714,
+                    "book_name": "dict"
+                }
+            ]
+        },
+        "10": {
+            "max_paragraph": 1320,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 13,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "byākaraṇa ganthasaṅgaha",
+                        "subodhālaṅkāra",
+                        "nirutti"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1320,
+                    "book_name": "subo"
+                }
+            ]
+        },
+        "11": {
+            "max_paragraph": 2885,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 14,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "subodhālaṅkāra",
+                        "nirutti",
+                        "byākaraṇa ganthasaṅgaha",
+                        "añña",
+                        "subodhālaṅkāraṭīkā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2885,
+                    "book_name": "subo"
+                }
+            ]
+        },
+        "12": {
+            "max_paragraph": 847,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 15,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "avatāra",
+                        "nirutti",
+                        "byākaraṇa ganthasaṅgaha",
+                        "añña",
+                        "bālāvatāra"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 847,
+                    "book_name": ""
+                }
+            ]
+        },
+        "13": {
+            "max_paragraph": 3262,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 16,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "moggallānasuttapāṭha",
+                        "añña",
+                        "byākaraṇa ganthasaṅgaha",
+                        "nirutti"
+                    ]
+                },
+                {
+                    "book_id": 17,
+                    "from_paragraph": 1105,
+                    "type": "unknown",
+                    "tags": [
+                        "moggallānabyākaraṇa",
+                        "byākaraṇa ganthasaṅgaha",
+                        "añña",
+                        "nirutti"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 3262,
+                    "book_name": ""
+                }
+            ]
+        },
+        "14": {
+            "max_paragraph": 3486,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 18,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "byākaraṇa ganthasaṅgaha",
+                        "kaccāyanabyākaraṇa",
+                        "nirutti",
+                        "añña"
+                    ]
+                },
+                {
+                    "book_id": 19,
+                    "from_paragraph": 715,
+                    "type": "unknown",
+                    "tags": [
+                        "byākaraṇa ganthasaṅgaha",
+                        "añña",
+                        "nirutti",
+                        "kaccāyanabyākaraṇa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 3486,
+                    "book_name": ""
+                }
+            ]
+        },
+        "15": {
+            "max_paragraph": 2980,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 20,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "padamālā",
+                        "saddanītippakaraṇa",
+                        "nirutti",
+                        "byākaraṇa ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2980,
+                    "book_name": ""
+                }
+            ]
+        },
+        "16": {
+            "max_paragraph": 3401,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 21,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "dhātumālā",
+                        "saddanītippakaraṇa",
+                        "nirutti",
+                        "byākaraṇa ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 3401,
+                    "book_name": ""
+                }
+            ]
+        },
+        "17": {
+            "max_paragraph": 4055,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 22,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "padarūpasiddhi",
+                        "byākaraṇa ganthasaṅgaha",
+                        "nirutti"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 4055,
+                    "book_name": ""
+                }
+            ]
+        },
+        "18": {
+            "max_paragraph": 1579,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 23,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "moggallāna pañcikā ṭīkā",
+                        "byākaraṇa ganthasaṅgaha",
+                        "añña",
+                        "nirutti"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1579,
+                    "book_name": ""
+                }
+            ]
+        },
+        "19": {
+            "max_paragraph": 2493,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 24,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "nirutti",
+                        "byākaraṇa ganthasaṅgaha",
+                        "añña",
+                        "payogasiddhipāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2493,
+                    "book_name": ""
+                }
+            ]
+        },
+        "20": {
+            "max_paragraph": 318,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 25,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "vuttodaya",
+                        "nirutti",
+                        "byākaraṇa ganthasaṅgaha",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 318,
+                    "book_name": ""
+                }
+            ]
+        },
+        "21": {
+            "max_paragraph": 3963,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 26,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "byākaraṇa ganthasaṅgaha",
+                        "abhidhānappadīpikā",
+                        "nirutti"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 3932,
+                    "book_name": "dict"
+                },
+                {
+                    "para_from": 3933,
+                    "para_to": 3963,
+                    "book_name": ""
+                }
+            ]
+        },
+        "22": {
+            "max_paragraph": 6144,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 27,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "niruttidīpanīpāṭha",
+                        "añña",
+                        "nirutti",
+                        "leḍī sayādo"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 6144,
+                    "book_name": ""
+                }
+            ]
+        },
+        "23": {
+            "max_paragraph": 1498,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 28,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "paramatthadīpanī",
+                        "abhidhammatthasaṅgaha",
+                        "abhidhamma",
+                        "leḍī sayādo",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1498,
+                    "book_name": "abhi9"
+                }
+            ]
+        },
+        "24": {
+            "max_paragraph": 434,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 29,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "abhidhamma",
+                        "abhidhammatthasaṅgaha",
+                        "anudīpanīpāṭha",
+                        "leḍī sayādo",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 434,
+                    "book_name": "abhi9"
+                }
+            ]
+        },
+        "25": {
+            "max_paragraph": 249,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 30,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "paṭṭhānuddesa dīpanīpāṭha",
+                        "abhidhamma",
+                        "leḍī sayādo",
+                        "añña",
+                        "paṭṭhāna"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 249,
+                    "book_name": ""
+                }
+            ]
+        },
+        "26": {
+            "max_paragraph": 1960,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 31,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "dīpanī",
+                        "añña",
+                        "nīti-ganthasaṅgaha",
+                        "caturārakkhadīpanī"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1960,
+                    "book_name": ""
+                }
+            ]
+        },
+        "27": {
+            "max_paragraph": 1347,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 32,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "kavidappaṇanīti",
+                        "nīti-ganthasaṅgaha",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1347,
+                    "book_name": ""
+                }
+            ]
+        },
+        "28": {
+            "max_paragraph": 243,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 33,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "nītimañjarī",
+                        "nīti-ganthasaṅgaha",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 243,
+                    "book_name": ""
+                }
+            ]
+        },
+        "29": {
+            "max_paragraph": 1333,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 34,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "dhammanīti",
+                        "nīti-ganthasaṅgaha",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1333,
+                    "book_name": ""
+                }
+            ]
+        },
+        "30": {
+            "max_paragraph": 1283,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 35,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "nīti-ganthasaṅgaha",
+                        "mahārahanīti"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1283,
+                    "book_name": ""
+                }
+            ]
+        },
+        "31": {
+            "max_paragraph": 876,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 36,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "lokanīti",
+                        "nīti-ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 876,
+                    "book_name": ""
+                }
+            ]
+        },
+        "32": {
+            "max_paragraph": 266,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 37,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "suttantanīti",
+                        "nīti-ganthasaṅgaha"
+                    ]
+                },
+                {
+                    "book_id": 38,
+                    "from_paragraph": 134,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "nīti-ganthasaṅgaha",
+                        "suttantanīti"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 266,
+                    "book_name": ""
+                }
+            ]
+        },
+        "33": {
+            "max_paragraph": 545,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 39,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "sūrassatīnīti",
+                        "nīti-ganthasaṅgaha",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 545,
+                    "book_name": ""
+                }
+            ]
+        },
+        "34": {
+            "max_paragraph": 552,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 40,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "nīti-ganthasaṅgaha",
+                        "cāṇakyanītipāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 552,
+                    "book_name": ""
+                }
+            ]
+        },
+        "35": {
+            "max_paragraph": 1847,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 41,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "naradakkhadīpanī",
+                        "añña",
+                        "nīti-ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1847,
+                    "book_name": ""
+                }
+            ]
+        },
+        "36": {
+            "max_paragraph": 2457,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 42,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "rasavāhinī",
+                        "añña",
+                        "pakiṇṇaka-ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2457,
+                    "book_name": ""
+                }
+            ]
+        },
+        "37": {
+            "max_paragraph": 433,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 43,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "sīmavisodhanī",
+                        "pakiṇṇaka",
+                        "pakiṇṇaka-ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 433,
+                    "book_name": ""
+                }
+            ]
+        },
+        "38": {
+            "max_paragraph": 2613,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 44,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "pakiṇṇaka-ganthasaṅgaha",
+                        "vessantarāgīti",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2613,
+                    "book_name": ""
+                }
+            ]
+        },
+        "39": {
+            "max_paragraph": 349,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 45,
+                    "from_paragraph": 3,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "dīghanikāya",
+                        "sutta",
+                        "saṅgayana-puccha vissajjanā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 349,
+                    "book_name": ""
+                }
+            ]
+        },
+        "40": {
+            "max_paragraph": 688,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 46,
+                    "from_paragraph": 3,
+                    "type": "unknown",
+                    "tags": [
+                        "sutta",
+                        "saṅgayana-puccha vissajjanā",
+                        "añña",
+                        "majjhimanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 688,
+                    "book_name": ""
+                }
+            ]
+        },
+        "41": {
+            "max_paragraph": 1256,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 47,
+                    "from_paragraph": 3,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "saṅgayana-puccha vissajjanā",
+                        "saṃyuttanikāya",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1256,
+                    "book_name": ""
+                }
+            ]
+        },
+        "42": {
+            "max_paragraph": 907,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 48,
+                    "from_paragraph": 3,
+                    "type": "unknown",
+                    "tags": [
+                        "saṅgayana-puccha vissajjanā",
+                        "añña",
+                        "sutta",
+                        "aṅguttaranikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 907,
+                    "book_name": ""
+                }
+            ]
+        },
+        "43": {
+            "max_paragraph": 1554,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 49,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "vinaya",
+                        "saṅgayana-puccha vissajjanā",
+                        "añña",
+                        "vinayapiṭaka"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1554,
+                    "book_name": ""
+                }
+            ]
+        },
+        "44": {
+            "max_paragraph": 296,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 50,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "abhidhammapiṭaka",
+                        "abhidhamma",
+                        "añña",
+                        "saṅgayana-puccha vissajjanā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 296,
+                    "book_name": ""
+                }
+            ]
+        },
+        "45": {
+            "max_paragraph": 503,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 51,
+                    "from_paragraph": 2,
+                    "type": "atthakatha",
+                    "tags": [
+                        "saṅgayana-puccha vissajjanā",
+                        "añña",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 503,
+                    "book_name": ""
+                }
+            ]
+        },
+        "46": {
+            "max_paragraph": 1240,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 52,
+                    "from_paragraph": 1,
+                    "type": "tika",
+                    "tags": [
+                        "ṭīkā",
+                        "milidaṭīkā",
+                        "sihaḷa-ganthasaṅgaha",
+                        "milindapañha",
+                        "añña",
+                        "khuddakanikāya",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1240,
+                    "book_name": "kn18"
+                }
+            ]
+        },
+        "47": {
+            "max_paragraph": 1640,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 53,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "padamañjarī",
+                        "nirutti",
+                        "sihaḷa-ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1640,
+                    "book_name": ""
+                }
+            ]
+        },
+        "48": {
+            "max_paragraph": 1095,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 54,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "sihaḷa-ganthasaṅgaha",
+                        "añña",
+                        "nirutti",
+                        "padasādhana"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1095,
+                    "book_name": ""
+                }
+            ]
+        },
+        "49": {
+            "max_paragraph": 83,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 55,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "sihaḷa-ganthasaṅgaha",
+                        "añña",
+                        "nirutti",
+                        "saddabindupakaraṇa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 83,
+                    "book_name": ""
+                }
+            ]
+        },
+        "50": {
+            "max_paragraph": 494,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 56,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "kaccāyanadhātumañjūsā",
+                        "añña",
+                        "nirutti",
+                        "sihaḷa-ganthasaṅgaha",
+                        "kaccāyana"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 494,
+                    "book_name": ""
+                }
+            ]
+        },
+        "51": {
+            "max_paragraph": 3057,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 57,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "sihaḷa-ganthasaṅgaha",
+                        "añña",
+                        "samantakūṭavaṇṇanā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 3057,
+                    "book_name": ""
+                }
+            ]
+        },
+        "52": {
+            "max_paragraph": 1703,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 58,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "sihaḷa-ganthasaṅgaha",
+                        "añña",
+                        "moggallāna vuttivivaraṇapañcikā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1703,
+                    "book_name": ""
+                }
+            ]
+        },
+        "53": {
+            "max_paragraph": 709,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 59,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "sihaḷa-ganthasaṅgaha",
+                        "thupavaṃsa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 709,
+                    "book_name": ""
+                }
+            ]
+        },
+        "54": {
+            "max_paragraph": 1642,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 60,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "dāṭhāvaṃsa",
+                        "añña",
+                        "sihaḷa-ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1642,
+                    "book_name": ""
+                }
+            ]
+        },
+        "55": {
+            "max_paragraph": 118,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 61,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "sihaḷa-ganthasaṅgaha",
+                        "dhātupāṭha vilāsiniyā",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 118,
+                    "book_name": ""
+                }
+            ]
+        },
+        "56": {
+            "max_paragraph": 491,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 62,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "sihaḷa-ganthasaṅgaha",
+                        "añña",
+                        "dhātuvaṃsa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 491,
+                    "book_name": ""
+                }
+            ]
+        },
+        "57": {
+            "max_paragraph": 364,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 63,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "sihaḷa-ganthasaṅgaha",
+                        "añña",
+                        "hatthavanagallavihāravaṃsa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 364,
+                    "book_name": ""
+                }
+            ]
+        },
+        "58": {
+            "max_paragraph": 1649,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 64,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "jinacaritaya",
+                        "añña",
+                        "sihaḷa-ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1649,
+                    "book_name": ""
+                }
+            ]
+        },
+        "59": {
+            "max_paragraph": 9907,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 65,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "sihaḷa-ganthasaṅgaha",
+                        "jinavaṃsadīpa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 9907,
+                    "book_name": ""
+                }
+            ]
+        },
+        "60": {
+            "max_paragraph": 501,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 66,
+                    "from_paragraph": 1,
+                    "type": "unknown",
+                    "tags": [
+                        "sihaḷa-ganthasaṅgaha",
+                        "añña",
+                        "telakaṭāhagāthā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 501,
+                    "book_name": ""
+                }
+            ]
+        },
+        "61": {
+            "max_paragraph": 152,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 67,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "cūḷaganthavaṃsapāḷi",
+                        "vaṃsa ganthasaṅgaha",
+                        "añña",
+                        "vaṃsa-ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 152,
+                    "book_name": ""
+                }
+            ]
+        },
+        "62": {
+            "max_paragraph": 971,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 68,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "añña",
+                        "vaṃsa ganthasaṅgaha",
+                        "vaṃsa-ganthasaṅgaha",
+                        "sāsanavaṃsappadīpikā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 971,
+                    "book_name": ""
+                }
+            ]
+        },
+        "63": {
+            "max_paragraph": 15944,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 69,
+                    "from_paragraph": 2,
+                    "type": "unknown",
+                    "tags": [
+                        "mahāvaṃsapāḷi",
+                        "vaṃsa ganthasaṅgaha",
+                        "añña",
+                        "vaṃsa-ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 15944,
+                    "book_name": ""
+                }
+            ]
+        },
+        "64": {
+            "max_paragraph": 2240,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 70,
+                    "from_paragraph": 2,
+                    "type": "atthakatha",
+                    "tags": [
+                        "visuddhimagga",
+                        "aṭṭhakathā",
+                        "paṭhamo bhāgo",
+                        "añña"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2240,
+                    "book_name": "vism"
+                }
+            ]
+        },
+        "65": {
+            "max_paragraph": 2192,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 71,
+                    "from_paragraph": 2,
+                    "type": "atthakatha",
+                    "tags": [
+                        "visuddhimagga",
+                        "dutiyo bhāgo",
+                        "añña",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2192,
+                    "book_name": "vism"
+                }
+            ]
+        },
+        "66": {
+            "max_paragraph": 1613,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 72,
+                    "from_paragraph": 2,
+                    "type": "tika",
+                    "tags": [
+                        "ṭīkā",
+                        "paṭhamo bhāgo",
+                        "mahāṭīkā",
+                        "añña",
+                        "visuddhimagga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1613,
+                    "book_name": "vism"
+                }
+            ]
+        },
+        "67": {
+            "max_paragraph": 1851,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 73,
+                    "from_paragraph": 2,
+                    "type": "tika",
+                    "tags": [
+                        "añña",
+                        "mahāṭīkā",
+                        "dutiyo bhāgo",
+                        "visuddhimagga",
+                        "ṭīkā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1851,
+                    "book_name": "vism"
+                }
+            ]
+        },
+        "68": {
+            "max_paragraph": 981,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 74,
+                    "from_paragraph": 2,
+                    "type": "tika",
+                    "tags": [
+                        "visuddhimagga",
+                        "añña",
+                        "ṭīkā",
+                        "nidānakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 981,
+                    "book_name": ""
+                }
+            ]
+        },
+        "69": {
+            "max_paragraph": 4241,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 75,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "mūla",
+                        "abhidhamma",
+                        "paṭṭhāna",
+                        "paṭṭhānapāḷi",
+                        "pāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 4241,
+                    "book_name": "abhi7"
+                }
+            ]
+        },
+        "70": {
+            "max_paragraph": 6004,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 76,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "abhidhamma",
+                        "mūla",
+                        "paṭṭhāna",
+                        "paṭṭhānapāḷi",
+                        "pāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 6004,
+                    "book_name": "abhi7"
+                }
+            ]
+        },
+        "71": {
+            "max_paragraph": 8046,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 77,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "paṭṭhānapāḷi",
+                        "paṭṭhāna",
+                        "abhidhamma",
+                        "mūla",
+                        "pāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 8046,
+                    "book_name": "abhi7"
+                }
+            ]
+        },
+        "72": {
+            "max_paragraph": 5169,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 78,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "paṭṭhānapāḷi",
+                        "abhidhamma",
+                        "mūla",
+                        "paṭṭhāna",
+                        "pāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 5169,
+                    "book_name": "abhi7"
+                }
+            ]
+        },
+        "73": {
+            "max_paragraph": 2436,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 79,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "dhammasaṅgaṇī",
+                        "mūla",
+                        "pāḷi",
+                        "abhidhamma"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2436,
+                    "book_name": "abhi1"
+                }
+            ]
+        },
+        "74": {
+            "max_paragraph": 3427,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 80,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "abhidhamma",
+                        "vibhaṅga",
+                        "pāḷi",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 3427,
+                    "book_name": "abhi2"
+                }
+            ]
+        },
+        "75": {
+            "max_paragraph": 673,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 81,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "abhidhamma",
+                        "pāḷi",
+                        "mūla",
+                        "dhātukathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 673,
+                    "book_name": "abhi3"
+                }
+            ]
+        },
+        "76": {
+            "max_paragraph": 561,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 82,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "mūla",
+                        "puggalapaññatti",
+                        "abhidhamma",
+                        "pāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 561,
+                    "book_name": "abhi4"
+                }
+            ]
+        },
+        "77": {
+            "max_paragraph": 3460,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 83,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "pāḷi",
+                        "kathāvatthu",
+                        "mūla",
+                        "abhidhamma"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 3460,
+                    "book_name": "abhi5"
+                }
+            ]
+        },
+        "78": {
+            "max_paragraph": 4774,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 84,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "yamakapāḷi",
+                        "pāḷi",
+                        "mūla",
+                        "abhidhamma",
+                        "yamaka"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 4774,
+                    "book_name": "abhi6"
+                }
+            ]
+        },
+        "79": {
+            "max_paragraph": 3859,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 85,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "yamaka",
+                        "abhidhamma",
+                        "mūla",
+                        "pāḷi",
+                        "yamakapāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 3859,
+                    "book_name": "abhi6"
+                }
+            ]
+        },
+        "80": {
+            "max_paragraph": 5586,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 86,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "yamaka",
+                        "abhidhamma",
+                        "mūla",
+                        "pāḷi",
+                        "yamakapāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 5586,
+                    "book_name": "abhi6"
+                }
+            ]
+        },
+        "81": {
+            "max_paragraph": 4301,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 87,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "mūla",
+                        "paṭṭhāna",
+                        "abhidhamma",
+                        "pāḷi",
+                        "paṭṭhānapāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 4301,
+                    "book_name": "abhi7"
+                }
+            ]
+        },
+        "82": {
+            "max_paragraph": 1604,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 88,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "aṅguttaranikāya",
+                        "sutta",
+                        "nipāta",
+                        "mūla",
+                        "pāḷi",
+                        "dasakanipāta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1604,
+                    "book_name": "an10"
+                }
+            ]
+        },
+        "83": {
+            "max_paragraph": 245,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 89,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "mūla",
+                        "sutta",
+                        "ekādasakanipāta",
+                        "pāḷi",
+                        "aṅguttaranikāya",
+                        "nipāta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 245,
+                    "book_name": "an11"
+                }
+            ]
+        },
+        "84": {
+            "max_paragraph": 400,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 90,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "nipāta",
+                        "sutta",
+                        "mūla",
+                        "pāḷi",
+                        "aṅguttaranikāya",
+                        "ekakanipāta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 400,
+                    "book_name": "an1"
+                }
+            ]
+        },
+        "85": {
+            "max_paragraph": 318,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 91,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "nipāta",
+                        "aṅguttaranikāya",
+                        "pāḷi",
+                        "dukanipāta",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 318,
+                    "book_name": "an2"
+                }
+            ]
+        },
+        "86": {
+            "max_paragraph": 1336,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 92,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "pāḷi",
+                        "tikanipāta",
+                        "nipāta",
+                        "aṅguttaranikāya",
+                        "mūla",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1336,
+                    "book_name": "an3"
+                }
+            ]
+        },
+        "87": {
+            "max_paragraph": 1950,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 93,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "nipāta",
+                        "catukkanipāta",
+                        "sutta",
+                        "mūla",
+                        "pāḷi",
+                        "aṅguttaranikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1950,
+                    "book_name": "an4"
+                }
+            ]
+        },
+        "88": {
+            "max_paragraph": 1632,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 94,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "pāḷi",
+                        "sutta",
+                        "mūla",
+                        "aṅguttaranikāya",
+                        "nipāta",
+                        "pañcakanipāta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1632,
+                    "book_name": "an5"
+                }
+            ]
+        },
+        "89": {
+            "max_paragraph": 857,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 95,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "nipāta",
+                        "chakkanipāta",
+                        "mūla",
+                        "sutta",
+                        "pāḷi",
+                        "aṅguttaranikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 857,
+                    "book_name": "an6"
+                }
+            ]
+        },
+        "90": {
+            "max_paragraph": 791,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 96,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "mūla",
+                        "pāḷi",
+                        "sattakanipāta",
+                        "aṅguttaranikāya",
+                        "nipāta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 791,
+                    "book_name": "an7"
+                }
+            ]
+        },
+        "91": {
+            "max_paragraph": 1041,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 97,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "aṭṭhakanipāta",
+                        "nipāta",
+                        "aṅguttaranikāya",
+                        "pāḷi",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1041,
+                    "book_name": "an8"
+                }
+            ]
+        },
+        "92": {
+            "max_paragraph": 502,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 98,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "nipāta",
+                        "sutta",
+                        "mūla",
+                        "pāḷi",
+                        "aṅguttaranikāya",
+                        "navakanipāta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 502,
+                    "book_name": "an9"
+                }
+            ]
+        },
+        "93": {
+            "max_paragraph": 1074,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 99,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sīlakkhandhavagga",
+                        "dīghanikāya",
+                        "pāḷi",
+                        "mūla",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1074,
+                    "book_name": "dn1"
+                }
+            ]
+        },
+        "94": {
+            "max_paragraph": 1559,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 100,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "mahāvagga",
+                        "sutta",
+                        "mūla",
+                        "pāḷi",
+                        "dīghanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1559,
+                    "book_name": "dn2"
+                }
+            ]
+        },
+        "95": {
+            "max_paragraph": 1910,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 101,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "dīghanikāya",
+                        "pāthikavagga",
+                        "pāḷi",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1910,
+                    "book_name": "dn3"
+                }
+            ]
+        },
+        "96": {
+            "max_paragraph": 2489,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 102,
+                    "from_paragraph": 4,
+                    "type": "atthakatha",
+                    "tags": [
+                        "abhidhamma",
+                        "aṭṭhasālinī",
+                        "aṭṭhakathā",
+                        "dhammasaṅgaṇī"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2489,
+                    "book_name": "abhi1"
+                }
+            ]
+        },
+        "97": {
+            "max_paragraph": 2346,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 103,
+                    "from_paragraph": 4,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "sammohavinodanī",
+                        "abhidhamma",
+                        "vibhaṅga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2346,
+                    "book_name": "abhi2"
+                }
+            ]
+        },
+        "98": {
+            "max_paragraph": 2611,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 104,
+                    "from_paragraph": 4,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "abhidhamma",
+                        "pañcapakaraṇa",
+                        "dhātukathā"
+                    ]
+                },
+                {
+                    "book_id": 105,
+                    "from_paragraph": 173,
+                    "type": "atthakatha",
+                    "tags": [
+                        "puggalapaññatti",
+                        "pañcapakaraṇa",
+                        "abhidhamma",
+                        "aṭṭhakathā"
+                    ]
+                },
+                {
+                    "book_id": 106,
+                    "from_paragraph": 474,
+                    "type": "atthakatha",
+                    "tags": [
+                        "kathāvatthu",
+                        "abhidhamma",
+                        "aṭṭhakathā",
+                        "pañcapakaraṇa"
+                    ]
+                },
+                {
+                    "book_id": 107,
+                    "from_paragraph": 1623,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "abhidhamma",
+                        "pañcapakaraṇa",
+                        "yamaka"
+                    ]
+                },
+                {
+                    "book_id": 108,
+                    "from_paragraph": 1880,
+                    "type": "atthakatha",
+                    "tags": [
+                        "paṭṭhāna",
+                        "aṭṭhakathā",
+                        "abhidhamma",
+                        "pañcapakaraṇa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 172,
+                    "book_name": "abhi3"
+                },
+                {
+                    "para_from": 173,
+                    "para_to": 473,
+                    "book_name": "abhi4"
+                },
+                {
+                    "para_from": 474,
+                    "para_to": 1622,
+                    "book_name": "abhi5"
+                },
+                {
+                    "para_from": 1623,
+                    "para_to": 1879,
+                    "book_name": "abhi6"
+                },
+                {
+                    "para_from": 1880,
+                    "para_to": 2611,
+                    "book_name": "abhi7"
+                }
+            ]
+        },
+        "99": {
+            "max_paragraph": 1624,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 109,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "sutta",
+                        "ekakanipāta",
+                        "manorathapūraṇī",
+                        "aṅguttaranikāya",
+                        "nipāta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 49,
+                    "book_name": "an1_A"
+                },
+                {
+                    "para_from": 50,
+                    "para_to": 1624,
+                    "book_name": "an1"
+                }
+            ]
+        },
+        "100": {
+            "max_paragraph": 1722,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 110,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "nipāta",
+                        "aṅguttaranikāya",
+                        "aṭṭhakathā",
+                        "manorathapūraṇī",
+                        "sutta",
+                        "dukanipāta"
+                    ]
+                },
+                {
+                    "book_id": 111,
+                    "from_paragraph": 325,
+                    "type": "atthakatha",
+                    "tags": [
+                        "manorathapūraṇī",
+                        "nipāta",
+                        "aṅguttaranikāya",
+                        "aṭṭhakathā",
+                        "sutta",
+                        "tikanipāta"
+                    ]
+                },
+                {
+                    "book_id": 112,
+                    "from_paragraph": 997,
+                    "type": "atthakatha",
+                    "tags": [
+                        "nipāta",
+                        "aṅguttaranikāya",
+                        "aṭṭhakathā",
+                        "sutta",
+                        "manorathapūraṇī",
+                        "catukkanipāta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 322,
+                    "book_name": "an2"
+                },
+                {
+                    "para_from": 323,
+                    "para_to": 994,
+                    "book_name": "an3"
+                },
+                {
+                    "para_from": 995,
+                    "para_to": 1722,
+                    "book_name": "an4"
+                }
+            ]
+        },
+        "101": {
+            "max_paragraph": 985,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 113,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "manorathapūraṇī",
+                        "sutta",
+                        "aṭṭhakathā",
+                        "aṅguttaranikāya",
+                        "nipāta",
+                        "pañcakanipāta"
+                    ]
+                },
+                {
+                    "book_id": 114,
+                    "from_paragraph": 503,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṅguttaranikāya",
+                        "aṭṭhakathā",
+                        "sutta",
+                        "manorathapūraṇī",
+                        "chakkanipāta",
+                        "nipāta"
+                    ]
+                },
+                {
+                    "book_id": 115,
+                    "from_paragraph": 792,
+                    "type": "atthakatha",
+                    "tags": [
+                        "sattakanipāta",
+                        "nipāta",
+                        "manorathapūraṇī",
+                        "sutta",
+                        "aṭṭhakathā",
+                        "aṅguttaranikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 500,
+                    "book_name": "an5"
+                },
+                {
+                    "para_from": 501,
+                    "para_to": 789,
+                    "book_name": "an6"
+                },
+                {
+                    "para_from": 790,
+                    "para_to": 985,
+                    "book_name": "an7"
+                }
+            ]
+        },
+        "102": {
+            "max_paragraph": 796,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 116,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "sutta",
+                        "aṭṭhakanipāta",
+                        "manorathapūraṇī",
+                        "nipāta",
+                        "aṅguttaranikāya"
+                    ]
+                },
+                {
+                    "book_id": 117,
+                    "from_paragraph": 275,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṅguttaranikāya",
+                        "manorathapūraṇī",
+                        "navakanipāta",
+                        "nipāta",
+                        "aṭṭhakathā",
+                        "sutta"
+                    ]
+                },
+                {
+                    "book_id": 118,
+                    "from_paragraph": 409,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṅguttaranikāya",
+                        "dasakanipāta",
+                        "aṭṭhakathā",
+                        "nipāta",
+                        "manorathapūraṇī",
+                        "sutta"
+                    ]
+                },
+                {
+                    "book_id": 119,
+                    "from_paragraph": 706,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṅguttaranikāya",
+                        "sutta",
+                        "aṭṭhakathā",
+                        "nipāta",
+                        "ekādasakanipāta",
+                        "manorathapūraṇī"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 272,
+                    "book_name": "an8"
+                },
+                {
+                    "para_from": 273,
+                    "para_to": 406,
+                    "book_name": "an9"
+                },
+                {
+                    "para_from": 407,
+                    "para_to": 703,
+                    "book_name": "an10"
+                },
+                {
+                    "para_from": 704,
+                    "para_to": 796,
+                    "book_name": "an11"
+                }
+            ]
+        },
+        "103": {
+            "max_paragraph": 1507,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 120,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "sutta",
+                        "sumaṅgalavilāsinī",
+                        "sīlakkhandhavagga",
+                        "dīghanikāya",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 198,
+                    "book_name": "dn1_A"
+                },
+                {
+                    "para_from": 199,
+                    "para_to": 1507,
+                    "book_name": "dn1"
+                }
+            ]
+        },
+        "104": {
+            "max_paragraph": 1712,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 121,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "sutta",
+                        "dīghanikāya",
+                        "mahāvagga",
+                        "sumaṅgalavilāsinī",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1712,
+                    "book_name": "dn2"
+                }
+            ]
+        },
+        "105": {
+            "max_paragraph": 1131,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 122,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "pāthikavagga",
+                        "sutta",
+                        "aṭṭhakathā",
+                        "dīghanikāya",
+                        "sumaṅgalavilāsinī"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1131,
+                    "book_name": "dn3"
+                }
+            ]
+        },
+        "106": {
+            "max_paragraph": 4548,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 123,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "khuddakanikāya",
+                        "aṭṭhakathā",
+                        "sutta",
+                        "therīgāthā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 4548,
+                    "book_name": "kn9"
+                }
+            ]
+        },
+        "107": {
+            "max_paragraph": 3875,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 124,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "apadāna",
+                        "sutta",
+                        "aṭṭhakathā",
+                        "therāpadāna",
+                        "khuddakanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2141,
+                    "book_name": "kn10_A"
+                },
+                {
+                    "para_from": 2142,
+                    "para_to": 3875,
+                    "book_name": "kn10_B"
+                }
+            ]
+        },
+        "108": {
+            "max_paragraph": 4137,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 125,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "vaṃsa ganthasaṅgaha",
+                        "buddhavaṃsa",
+                        "khuddakanikāya",
+                        "aṭṭhakathā",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 4137,
+                    "book_name": "kn12"
+                }
+            ]
+        },
+        "109": {
+            "max_paragraph": 2126,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 126,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "carita",
+                        "cariyāpiṭaka",
+                        "khuddakanikāya",
+                        "aṭṭhakathā",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2126,
+                    "book_name": "kn13"
+                }
+            ]
+        },
+        "110": {
+            "max_paragraph": 3117,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 127,
+                    "from_paragraph": 4,
+                    "type": "atthakatha",
+                    "tags": [
+                        "jātaka",
+                        "aṭṭhakathā",
+                        "khuddakanikāya",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 3117,
+                    "book_name": "kn14"
+                }
+            ]
+        },
+        "111": {
+            "max_paragraph": 2997,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 128,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "khuddakanikāya",
+                        "sutta",
+                        "jātaka",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2997,
+                    "book_name": "kn14"
+                }
+            ]
+        },
+        "112": {
+            "max_paragraph": 4868,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 129,
+                    "from_paragraph": 4,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "sutta",
+                        "khuddakanikāya",
+                        "jātaka"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 4868,
+                    "book_name": "kn14"
+                }
+            ]
+        },
+        "113": {
+            "max_paragraph": 6738,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 130,
+                    "from_paragraph": 4,
+                    "type": "atthakatha",
+                    "tags": [
+                        "sutta",
+                        "aṭṭhakathā",
+                        "khuddakanikāya",
+                        "jātaka"
+                    ]
+                },
+                {
+                    "book_id": 131,
+                    "from_paragraph": 5312,
+                    "type": "atthakatha",
+                    "tags": [
+                        "jātaka",
+                        "sutta",
+                        "aṭṭhakathā",
+                        "khuddakanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 6738,
+                    "book_name": "kn14"
+                }
+            ]
+        },
+        "114": {
+            "max_paragraph": 5079,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 132,
+                    "from_paragraph": 1,
+                    "type": "atthakatha",
+                    "tags": [
+                        "sutta",
+                        "jātaka",
+                        "khuddakanikāya",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 5079,
+                    "book_name": "kn14"
+                }
+            ]
+        },
+        "115": {
+            "max_paragraph": 3688,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 133,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "khuddakanikāya",
+                        "jātaka",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 3688,
+                    "book_name": "kn14"
+                }
+            ]
+        },
+        "116": {
+            "max_paragraph": 1235,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 134,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "khuddakapāṭha",
+                        "paramatthajotika",
+                        "khuddakanikāya",
+                        "aṭṭhakathā",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1235,
+                    "book_name": "kn1"
+                }
+            ]
+        },
+        "117": {
+            "max_paragraph": 6463,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 135,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "jātaka",
+                        "khuddakanikāya",
+                        "aṭṭhakathā",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 6463,
+                    "book_name": "kn14"
+                }
+            ]
+        },
+        "118": {
+            "max_paragraph": 1833,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 136,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "sutta",
+                        "niddesa",
+                        "mahāniddesa",
+                        "khuddakanikāya",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1833,
+                    "book_name": "kn15"
+                }
+            ]
+        },
+        "119": {
+            "max_paragraph": 753,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 137,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "sutta",
+                        "cūḷaniddesa",
+                        "niddesa",
+                        "khuddakanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 753,
+                    "book_name": "kn16"
+                }
+            ]
+        },
+        "120": {
+            "max_paragraph": 2526,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 138,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "khuddakanikāya",
+                        "aṭṭhakathā",
+                        "sutta",
+                        "paṭisambhidāmagga",
+                        "saddhammappakāsinī"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2526,
+                    "book_name": "kn17"
+                }
+            ]
+        },
+        "121": {
+            "max_paragraph": 1096,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 139,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "khuddakanikāya",
+                        "nettippakaraṇa",
+                        "netti",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1096,
+                    "book_name": "kn19"
+                }
+            ]
+        },
+        "122": {
+            "max_paragraph": 5231,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 140,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "sutta",
+                        "dhammapada",
+                        "khuddakanikāya",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 5231,
+                    "book_name": "kn2"
+                }
+            ]
+        },
+        "123": {
+            "max_paragraph": 1688,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 141,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "khuddakanikāya",
+                        "udāna",
+                        "sutta",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1688,
+                    "book_name": "kn3"
+                }
+            ]
+        },
+        "124": {
+            "max_paragraph": 1759,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 142,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "sutta",
+                        "khuddakanikāya",
+                        "itivuttaka",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1759,
+                    "book_name": "kn4"
+                }
+            ]
+        },
+        "125": {
+            "max_paragraph": 2689,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 143,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "suttanipāta",
+                        "sutta",
+                        "aṭṭhakathā",
+                        "khuddakanikāya",
+                        "nipāta",
+                        "paramatthajotika"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2689,
+                    "book_name": "kn5"
+                }
+            ]
+        },
+        "126": {
+            "max_paragraph": 4017,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 144,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "khuddakanikāya",
+                        "vimānavatthu",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 4017,
+                    "book_name": "kn6"
+                }
+            ]
+        },
+        "127": {
+            "max_paragraph": 3236,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 145,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "petavatthu",
+                        "sutta",
+                        "aṭṭhakathā",
+                        "khuddakanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 3236,
+                    "book_name": "kn7"
+                }
+            ]
+        },
+        "128": {
+            "max_paragraph": 5412,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 146,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "sutta",
+                        "khuddakanikāya",
+                        "theragāthā",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 5412,
+                    "book_name": "kn8"
+                }
+            ]
+        },
+        "129": {
+            "max_paragraph": 6607,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 147,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "theragāthā",
+                        "sutta",
+                        "aṭṭhakathā",
+                        "khuddakanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 6607,
+                    "book_name": "kn8"
+                }
+            ]
+        },
+        "130": {
+            "max_paragraph": 2912,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 148,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "mūlapaṇṇāsa",
+                        "aṭṭhakathā",
+                        "majjhimanikāya",
+                        "papañcasūdanī",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 46,
+                    "book_name": "mn1_A"
+                },
+                {
+                    "para_from": 47,
+                    "para_to": 2912,
+                    "book_name": "mn1"
+                }
+            ]
+        },
+        "131": {
+            "max_paragraph": 1339,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 149,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "sutta",
+                        "aṭṭhakathā",
+                        "papañcasūdanī",
+                        "majjhimapaṇṇāsa",
+                        "majjhimanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1339,
+                    "book_name": "mn2"
+                }
+            ]
+        },
+        "132": {
+            "max_paragraph": 1107,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 150,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "majjhimanikāya",
+                        "aṭṭhakathā",
+                        "papañcasūdanī",
+                        "sutta",
+                        "uparipaṇṇāsa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1107,
+                    "book_name": "mn3"
+                }
+            ]
+        },
+        "133": {
+            "max_paragraph": 1478,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 151,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "saṃyuttanikāya",
+                        "sagāthāvagga",
+                        "sāratthappakāsinī",
+                        "aṭṭhakathā",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 43,
+                    "book_name": "sn1_A"
+                },
+                {
+                    "para_from": 44,
+                    "para_to": 1478,
+                    "book_name": "sn1"
+                }
+            ]
+        },
+        "134": {
+            "max_paragraph": 978,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 152,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "sutta",
+                        "saṃyuttanikāya",
+                        "sāratthappakāsinī",
+                        "nidānavagga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 978,
+                    "book_name": "sn2"
+                }
+            ]
+        },
+        "135": {
+            "max_paragraph": 491,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 153,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "saṃyuttanikāya",
+                        "aṭṭhakathā",
+                        "sutta",
+                        "sāratthappakāsinī",
+                        "khandhavagga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 491,
+                    "book_name": "sn3"
+                }
+            ]
+        },
+        "136": {
+            "max_paragraph": 799,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 154,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "saḷāyatanavagga",
+                        "sāratthappakāsinī",
+                        "aṭṭhakathā",
+                        "sutta",
+                        "saṃyuttanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 799,
+                    "book_name": "sn4"
+                }
+            ]
+        },
+        "137": {
+            "max_paragraph": 1016,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 155,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "sāratthappakāsinī",
+                        "saṃyuttanikāya",
+                        "aṭṭhakathā",
+                        "sutta",
+                        "mahāvagga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1016,
+                    "book_name": "sn5"
+                }
+            ]
+        },
+        "138": {
+            "max_paragraph": 2974,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 156,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "pārājika",
+                        "vinaya",
+                        "aṭṭhakathā",
+                        "samantapāsādikā",
+                        "suttavibhaṅga",
+                        "mahāvibhaṅga",
+                        "pārājikakaṇḍa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2974,
+                    "book_name": "vin1"
+                }
+            ]
+        },
+        "139": {
+            "max_paragraph": 1466,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 157,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "mahāvibhaṅga",
+                        "suttavibhaṅga",
+                        "samantapāsādikā",
+                        "aṭṭhakathā",
+                        "vinaya",
+                        "pācittiya-aṭṭhakathā",
+                        "pācittiya"
+                    ]
+                },
+                {
+                    "book_id": 280,
+                    "from_paragraph": 925,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "samantapāsādikā",
+                        "vinaya",
+                        "bhikkhunīvibhaṅga",
+                        "pācittiya-aṭṭhakathā",
+                        "pācittiya",
+                        "suttavibhaṅga",
+                        "bhikkhunī"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1466,
+                    "book_name": "vin2"
+                }
+            ]
+        },
+        "140": {
+            "max_paragraph": 923,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 158,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "aṭṭhakathā",
+                        "khandhaka",
+                        "samantapāsādikā",
+                        "mahāvagga",
+                        "vinaya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 923,
+                    "book_name": "vin3"
+                }
+            ]
+        },
+        "141": {
+            "max_paragraph": 669,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 159,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "samantapāsādikā",
+                        "cūḷavagga",
+                        "khandhaka",
+                        "vinaya",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 669,
+                    "book_name": "vin4"
+                }
+            ]
+        },
+        "142": {
+            "max_paragraph": 661,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 160,
+                    "from_paragraph": 3,
+                    "type": "atthakatha",
+                    "tags": [
+                        "samantapāsādikā",
+                        "parivāra",
+                        "parivārapāḷi",
+                        "vinaya",
+                        "aṭṭhakathā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 661,
+                    "book_name": "vin5"
+                }
+            ]
+        },
+        "143": {
+            "max_paragraph": 14646,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 161,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "pāḷi",
+                        "apadāna",
+                        "therāpadāna",
+                        "khuddakanikāya",
+                        "sutta",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 14646,
+                    "book_name": "kn10"
+                }
+            ]
+        },
+        "144": {
+            "max_paragraph": 10673,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 162,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "khuddakanikāya",
+                        "therāpadāna",
+                        "apadāna",
+                        "pāḷi",
+                        "mūla",
+                        "sutta"
+                    ]
+                },
+                {
+                    "book_id": 163,
+                    "from_paragraph": 6534,
+                    "type": "mula",
+                    "tags": [
+                        "khuddakanikāya",
+                        "pāḷi",
+                        "apadāna",
+                        "therīapadāna",
+                        "sutta",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 6531,
+                    "book_name": "kn10"
+                },
+                {
+                    "para_from": 6532,
+                    "para_to": 10673,
+                    "book_name": "kn11"
+                }
+            ]
+        },
+        "145": {
+            "max_paragraph": 3282,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 164,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "mūla",
+                        "sutta",
+                        "khuddakanikāya",
+                        "pāḷi",
+                        "buddhavaṃsa",
+                        "vaṃsa ganthasaṅgaha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 3282,
+                    "book_name": "kn12"
+                }
+            ]
+        },
+        "146": {
+            "max_paragraph": 1199,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 165,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "carita",
+                        "pāḷi",
+                        "cariyāpiṭaka",
+                        "mūla",
+                        "sutta",
+                        "khuddakanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1199,
+                    "book_name": "kn13"
+                }
+            ]
+        },
+        "147": {
+            "max_paragraph": 11528,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 166,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "mūla",
+                        "sutta",
+                        "khuddakanikāya",
+                        "pāḷi",
+                        "jātaka"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 11528,
+                    "book_name": "kn14"
+                }
+            ]
+        },
+        "148": {
+            "max_paragraph": 10506,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 167,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "khuddakanikāya",
+                        "mūla",
+                        "pāḷi",
+                        "jātaka",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 10506,
+                    "book_name": "kn14"
+                }
+            ]
+        },
+        "149": {
+            "max_paragraph": 2927,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 168,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "niddesa",
+                        "khuddakanikāya",
+                        "sutta",
+                        "mahāniddesa",
+                        "pāḷi",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 2927,
+                    "book_name": "kn15"
+                }
+            ]
+        },
+        "150": {
+            "max_paragraph": 2771,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 169,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "khuddakanikāya",
+                        "sutta",
+                        "mūla",
+                        "pāḷi",
+                        "niddesa",
+                        "cūḷaniddesa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 2771,
+                    "book_name": "kn16"
+                }
+            ]
+        },
+        "151": {
+            "max_paragraph": 2044,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 170,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "khuddakanikāya",
+                        "pāḷi",
+                        "mūla",
+                        "paṭisambhidāmagga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 2044,
+                    "book_name": "kn17"
+                }
+            ]
+        },
+        "152": {
+            "max_paragraph": 2493,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 171,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "milindapañha",
+                        "khuddakanikāya",
+                        "sutta",
+                        "pāḷi",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 2493,
+                    "book_name": "kn18"
+                }
+            ]
+        },
+        "153": {
+            "max_paragraph": 1624,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 172,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "mūla",
+                        "sutta",
+                        "khuddakanikāya",
+                        "pāḷi",
+                        "nettippakaraṇa",
+                        "netti"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1624,
+                    "book_name": "kn19"
+                }
+            ]
+        },
+        "154": {
+            "max_paragraph": 276,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 173,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "khuddakapāṭha",
+                        "pāḷi",
+                        "mūla",
+                        "khuddakanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 276,
+                    "book_name": "kn1"
+                }
+            ]
+        },
+        "155": {
+            "max_paragraph": 1217,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 174,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "peṭakopadesa",
+                        "pāḷi",
+                        "mūla",
+                        "khuddakanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1217,
+                    "book_name": "kn20"
+                }
+            ]
+        },
+        "156": {
+            "max_paragraph": 1394,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 175,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "dhammapada",
+                        "pāḷi",
+                        "mūla",
+                        "khuddakanikāya",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1394,
+                    "book_name": "kn2"
+                }
+            ]
+        },
+        "157": {
+            "max_paragraph": 965,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 176,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "khuddakanikāya",
+                        "sutta",
+                        "udāna",
+                        "pāḷi",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 965,
+                    "book_name": "kn3"
+                }
+            ]
+        },
+        "158": {
+            "max_paragraph": 1194,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 177,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "itivuttaka",
+                        "pāḷi",
+                        "mūla",
+                        "khuddakanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1194,
+                    "book_name": "kn4"
+                }
+            ]
+        },
+        "159": {
+            "max_paragraph": 4051,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 178,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "suttanipāta",
+                        "nipāta",
+                        "pāḷi",
+                        "mūla",
+                        "khuddakanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 4051,
+                    "book_name": "kn5"
+                }
+            ]
+        },
+        "160": {
+            "max_paragraph": 3279,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 179,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "vimānavatthu",
+                        "pāḷi",
+                        "mūla",
+                        "khuddakanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 3279,
+                    "book_name": "kn6"
+                }
+            ]
+        },
+        "161": {
+            "max_paragraph": 2655,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 180,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "petavatthu",
+                        "pāḷi",
+                        "mūla",
+                        "khuddakanikāya",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 2655,
+                    "book_name": "kn7"
+                }
+            ]
+        },
+        "162": {
+            "max_paragraph": 4669,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 181,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "pāḷi",
+                        "sutta",
+                        "khuddakanikāya",
+                        "mūla",
+                        "theragāthā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 4669,
+                    "book_name": "kn8"
+                }
+            ]
+        },
+        "163": {
+            "max_paragraph": 1785,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 182,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "therīgāthā",
+                        "pāḷi",
+                        "mūla",
+                        "khuddakanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1785,
+                    "book_name": "kn9"
+                }
+            ]
+        },
+        "164": {
+            "max_paragraph": 1797,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 183,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "mūlapaṇṇāsa",
+                        "majjhimanikāya",
+                        "sutta",
+                        "mūla",
+                        "pāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1797,
+                    "book_name": "mn1"
+                }
+            ]
+        },
+        "165": {
+            "max_paragraph": 1690,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 184,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "majjhimapaṇṇāsa",
+                        "mūla",
+                        "sutta",
+                        "majjhimanikāya",
+                        "pāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1690,
+                    "book_name": "mn2"
+                }
+            ]
+        },
+        "166": {
+            "max_paragraph": 1463,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 185,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "majjhimanikāya",
+                        "sutta",
+                        "mūla",
+                        "pāḷi",
+                        "uparipaṇṇāsa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1463,
+                    "book_name": "mn3"
+                }
+            ]
+        },
+        "167": {
+            "max_paragraph": 3498,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 186,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "sagāthāvagga",
+                        "saṃyuttanikāya",
+                        "pāḷi",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 3498,
+                    "book_name": "sn1"
+                }
+            ]
+        },
+        "168": {
+            "max_paragraph": 1427,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 187,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "mūla",
+                        "nidānavagga",
+                        "saṃyuttanikāya",
+                        "pāḷi",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1427,
+                    "book_name": "sn2"
+                }
+            ]
+        },
+        "169": {
+            "max_paragraph": 1467,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 188,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "pāḷi",
+                        "saṃyuttanikāya",
+                        "sutta",
+                        "khandhavagga",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1467,
+                    "book_name": "sn3"
+                }
+            ]
+        },
+        "170": {
+            "max_paragraph": 2020,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 189,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "mūla",
+                        "pāḷi",
+                        "saṃyuttanikāya",
+                        "saḷāyatanavagga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 2020,
+                    "book_name": "sn4"
+                }
+            ]
+        },
+        "171": {
+            "max_paragraph": 2852,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 190,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "sutta",
+                        "saṃyuttanikāya",
+                        "mahāvagga",
+                        "pāḷi",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 2852,
+                    "book_name": "sn5"
+                }
+            ]
+        },
+        "172": {
+            "max_paragraph": 722,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 191,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "abhidhamma",
+                        "mūlaṭīkā",
+                        "ṭīkā",
+                        "dhammasaṅgaṇī"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 722,
+                    "book_name": "abhi1"
+                }
+            ]
+        },
+        "173": {
+            "max_paragraph": 2219,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 192,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "vibhaṅga",
+                        "mūlaṭīkā",
+                        "abhidhamma",
+                        "ṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 193,
+                    "from_paragraph": 1155,
+                    "type": "tika",
+                    "tags": [
+                        "ṭīkā",
+                        "mūlaṭīkā",
+                        "abhidhamma",
+                        "vibhaṅga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2219,
+                    "book_name": "abhi2"
+                }
+            ]
+        },
+        "174": {
+            "max_paragraph": 1487,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 194,
+                    "from_paragraph": 4,
+                    "type": "tika",
+                    "tags": [
+                        "ṭīkā",
+                        "abhidhamma",
+                        "dhātukathā",
+                        "pañcapakaraṇa",
+                        "mūlaṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 195,
+                    "from_paragraph": 100,
+                    "type": "tika",
+                    "tags": [
+                        "abhidhamma",
+                        "pañcapakaraṇa",
+                        "puggalapaññatti",
+                        "mūlaṭīkā",
+                        "ṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 196,
+                    "from_paragraph": 201,
+                    "type": "tika",
+                    "tags": [
+                        "pañcapakaraṇa",
+                        "abhidhamma",
+                        "ṭīkā",
+                        "mūlaṭīkā",
+                        "kathāvatthu"
+                    ]
+                },
+                {
+                    "book_id": 197,
+                    "from_paragraph": 865,
+                    "type": "tika",
+                    "tags": [
+                        "yamaka",
+                        "abhidhamma",
+                        "pañcapakaraṇa",
+                        "ṭīkā",
+                        "mūlaṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 198,
+                    "from_paragraph": 1059,
+                    "type": "tika",
+                    "tags": [
+                        "paṭṭhāna",
+                        "pañcapakaraṇa",
+                        "mūlaṭīkā",
+                        "ṭīkā",
+                        "abhidhamma"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 99,
+                    "book_name": "abhi3"
+                },
+                {
+                    "para_from": 100,
+                    "para_to": 200,
+                    "book_name": "abhi4"
+                },
+                {
+                    "para_from": 201,
+                    "para_to": 864,
+                    "book_name": "abhi5"
+                },
+                {
+                    "para_from": 865,
+                    "para_to": 1058,
+                    "book_name": "abhi6"
+                },
+                {
+                    "para_from": 1059,
+                    "para_to": 1487,
+                    "book_name": "abhi7"
+                }
+            ]
+        },
+        "175": {
+            "max_paragraph": 725,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 199,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "dhammasaṅgaṇī",
+                        "abhidhamma",
+                        "ṭīkā",
+                        "anuṭīkā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 725,
+                    "book_name": "abhi1"
+                }
+            ]
+        },
+        "176": {
+            "max_paragraph": 1511,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 200,
+                    "from_paragraph": 4,
+                    "type": "tika",
+                    "tags": [
+                        "abhidhamma",
+                        "ṭīkā",
+                        "anuṭīkā",
+                        "dhātukathā",
+                        "pañcapakaraṇa"
+                    ]
+                },
+                {
+                    "book_id": 201,
+                    "from_paragraph": 117,
+                    "type": "tika",
+                    "tags": [
+                        "anuṭīkā",
+                        "ṭīkā",
+                        "abhidhamma",
+                        "puggalapaññatti",
+                        "pañcapakaraṇa"
+                    ]
+                },
+                {
+                    "book_id": 202,
+                    "from_paragraph": 213,
+                    "type": "tika",
+                    "tags": [
+                        "abhidhamma",
+                        "ṭīkā",
+                        "anuṭīkā",
+                        "pañcapakaraṇa",
+                        "kathāvatthu"
+                    ]
+                },
+                {
+                    "book_id": 203,
+                    "from_paragraph": 917,
+                    "type": "tika",
+                    "tags": [
+                        "pañcapakaraṇa",
+                        "anuṭīkā",
+                        "yamaka",
+                        "abhidhamma",
+                        "ṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 204,
+                    "from_paragraph": 1139,
+                    "type": "tika",
+                    "tags": [
+                        "anuṭīkā",
+                        "ṭīkā",
+                        "abhidhamma",
+                        "paṭṭhāna",
+                        "pañcapakaraṇa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 116,
+                    "book_name": "abhi3"
+                },
+                {
+                    "para_from": 117,
+                    "para_to": 212,
+                    "book_name": "abhi4"
+                },
+                {
+                    "para_from": 213,
+                    "para_to": 916,
+                    "book_name": "abhi5"
+                },
+                {
+                    "para_from": 917,
+                    "para_to": 1138,
+                    "book_name": "abhi6"
+                },
+                {
+                    "para_from": 1139,
+                    "para_to": 1511,
+                    "book_name": "abhi7"
+                }
+            ]
+        },
+        "177": {
+            "max_paragraph": 15499,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 205,
+                    "from_paragraph": 2,
+                    "type": "tika",
+                    "tags": [
+                        "abhidhamma",
+                        "ṭīkā",
+                        "abhidhammāvatāro",
+                        "abhidhammāvatāra"
+                    ]
+                },
+                {
+                    "book_id": 206,
+                    "from_paragraph": 4805,
+                    "type": "tika",
+                    "tags": [
+                        "nāmarūpapariccheda",
+                        "abhidhamma",
+                        "ṭīkā",
+                        "abhidhammāvatāro"
+                    ]
+                },
+                {
+                    "book_id": 207,
+                    "from_paragraph": 10627,
+                    "type": "tika",
+                    "tags": [
+                        "abhidhammāvatāro",
+                        "abhidhamma",
+                        "ṭīkā",
+                        "paramatthavinicchaya"
+                    ]
+                },
+                {
+                    "book_id": 208,
+                    "from_paragraph": 14272,
+                    "type": "tika",
+                    "tags": [
+                        "saccasaṅkhepa",
+                        "ṭīkā",
+                        "abhidhamma",
+                        "abhidhammāvatāro"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 4804,
+                    "book_name": "abhi8"
+                },
+                {
+                    "para_from": 4805,
+                    "para_to": 15499,
+                    "book_name": ""
+                }
+            ]
+        },
+        "178": {
+            "max_paragraph": 1838,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 209,
+                    "from_paragraph": 2,
+                    "type": "tika",
+                    "tags": [
+                        "abhidhammatthasaṅgaha",
+                        "abhidhamma",
+                        "ṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 210,
+                    "from_paragraph": 875,
+                    "type": "tika",
+                    "tags": [
+                        "abhidhammatthasaṅgaha",
+                        "ṭīkā",
+                        "abhidhamma"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1838,
+                    "book_name": "abhi9"
+                }
+            ]
+        },
+        "179": {
+            "max_paragraph": 2598,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 211,
+                    "from_paragraph": 2,
+                    "type": "tika",
+                    "tags": [
+                        "abhidhammāvatāra",
+                        "avatāra",
+                        "ṭīkā",
+                        "purāṇaṭīkā",
+                        "abhidhamma"
+                    ]
+                },
+                {
+                    "book_id": 212,
+                    "from_paragraph": 872,
+                    "type": "tika",
+                    "tags": [
+                        "abhidhammāvatāra",
+                        "ṭīkā",
+                        "avatāra",
+                        "abhidhamma",
+                        "abhinavaṭīkā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2572,
+                    "book_name": "abhi8"
+                },
+                {
+                    "para_from": 2573,
+                    "para_to": 2598,
+                    "book_name": "abhi8_A"
+                }
+            ]
+        },
+        "180": {
+            "max_paragraph": 2826,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 213,
+                    "from_paragraph": 2,
+                    "type": "tika",
+                    "tags": [
+                        "mātikā",
+                        "abhidhammamātikā",
+                        "pāḷi",
+                        "abhidhamma",
+                        "ṭīkā",
+                        "abhidhammamātikāpāḷi"
+                    ]
+                },
+                {
+                    "book_id": 214,
+                    "from_paragraph": 1221,
+                    "type": "tika",
+                    "tags": [
+                        "pāḷi",
+                        "abhidhamma",
+                        "ṭīkā",
+                        "abhidhammamātikā",
+                        "mohavicchedanī",
+                        "mātikā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2826,
+                    "book_name": ""
+                }
+            ]
+        },
+        "181": {
+            "max_paragraph": 1173,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 215,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "sutta",
+                        "aṅguttaranikāya",
+                        "ekakanipāta",
+                        "sāratthamañjūsā",
+                        "nipāta",
+                        "ṭīkā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 138,
+                    "book_name": "an1_A"
+                },
+                {
+                    "para_from": 139,
+                    "para_to": 1173,
+                    "book_name": "an1"
+                }
+            ]
+        },
+        "182": {
+            "max_paragraph": 1925,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 216,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "aṅguttaranikāya",
+                        "ṭīkā",
+                        "dukanipāta",
+                        "sāratthamañjūsā",
+                        "nipāta",
+                        "sutta"
+                    ]
+                },
+                {
+                    "book_id": 217,
+                    "from_paragraph": 324,
+                    "type": "tika",
+                    "tags": [
+                        "sutta",
+                        "sāratthamañjūsā",
+                        "tikanipāta",
+                        "nipāta",
+                        "aṅguttaranikāya",
+                        "ṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 218,
+                    "from_paragraph": 1023,
+                    "type": "tika",
+                    "tags": [
+                        "sutta",
+                        "aṅguttaranikāya",
+                        "ṭīkā",
+                        "nipāta",
+                        "catukkanipāta",
+                        "sāratthamañjūsā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 321,
+                    "book_name": "an2"
+                },
+                {
+                    "para_from": 322,
+                    "para_to": 1020,
+                    "book_name": "an3"
+                },
+                {
+                    "para_from": 1021,
+                    "para_to": 1925,
+                    "book_name": "an4"
+                }
+            ]
+        },
+        "183": {
+            "max_paragraph": 1026,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 219,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "ṭīkā",
+                        "nipāta",
+                        "sāratthamañjūsā",
+                        "pañcakanipāta",
+                        "aṅguttaranikāya",
+                        "sutta"
+                    ]
+                },
+                {
+                    "book_id": 220,
+                    "from_paragraph": 470,
+                    "type": "tika",
+                    "tags": [
+                        "sutta",
+                        "ṭīkā",
+                        "aṅguttaranikāya",
+                        "nipāta",
+                        "chakkanipāta",
+                        "sāratthamañjūsā"
+                    ]
+                },
+                {
+                    "book_id": 221,
+                    "from_paragraph": 793,
+                    "type": "tika",
+                    "tags": [
+                        "nipāta",
+                        "sutta",
+                        "aṅguttaranikāya",
+                        "ṭīkā",
+                        "sattakanipāta",
+                        "sāratthamañjūsā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 467,
+                    "book_name": "an5"
+                },
+                {
+                    "para_from": 468,
+                    "para_to": 790,
+                    "book_name": "an6"
+                },
+                {
+                    "para_from": 791,
+                    "para_to": 1026,
+                    "book_name": "an7"
+                }
+            ]
+        },
+        "184": {
+            "max_paragraph": 868,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 222,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "aṅguttaranikāya",
+                        "aṭṭhakanipāta",
+                        "ṭīkā",
+                        "sāratthamañjūsā",
+                        "nipāta",
+                        "sutta"
+                    ]
+                },
+                {
+                    "book_id": 223,
+                    "from_paragraph": 278,
+                    "type": "tika",
+                    "tags": [
+                        "sutta",
+                        "sāratthamañjūsā",
+                        "navakanipāta",
+                        "nipāta",
+                        "aṅguttaranikāya",
+                        "ṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 224,
+                    "from_paragraph": 466,
+                    "type": "tika",
+                    "tags": [
+                        "nipāta",
+                        "ṭīkā",
+                        "sutta",
+                        "aṅguttaranikāya",
+                        "dasakanipāta",
+                        "sāratthamañjūsā"
+                    ]
+                },
+                {
+                    "book_id": 225,
+                    "from_paragraph": 776,
+                    "type": "tika",
+                    "tags": [
+                        "aṅguttaranikāya",
+                        "ṭīkā",
+                        "sutta",
+                        "nipāta",
+                        "ekādasakanipāta",
+                        "sāratthamañjūsā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 275,
+                    "book_name": "an8"
+                },
+                {
+                    "para_from": 276,
+                    "para_to": 463,
+                    "book_name": "an9"
+                },
+                {
+                    "para_from": 464,
+                    "para_to": 773,
+                    "book_name": "an10"
+                },
+                {
+                    "para_from": 774,
+                    "para_to": 868,
+                    "book_name": "an11"
+                }
+            ]
+        },
+        "185": {
+            "max_paragraph": 1391,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 226,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "dīghanikāya",
+                        "ṭīkā",
+                        "sīlakkhandhavaggaṭīkā",
+                        "līnatthappakāsanā",
+                        "sīlakkhandhavagga",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 120,
+                    "book_name": "dn1_A"
+                },
+                {
+                    "para_from": 121,
+                    "para_to": 1391,
+                    "book_name": "dn1"
+                }
+            ]
+        },
+        "186": {
+            "max_paragraph": 1495,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 227,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "sutta",
+                        "mahāvagga",
+                        "līnatthappakāsanā",
+                        "ṭīkā",
+                        "dīghanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1495,
+                    "book_name": "dn2"
+                }
+            ]
+        },
+        "187": {
+            "max_paragraph": 1132,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 228,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "pāthikavagga",
+                        "sutta",
+                        "dīghanikāya",
+                        "ṭīkā",
+                        "līnatthappakāsanā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1132,
+                    "book_name": "dn3"
+                }
+            ]
+        },
+        "188": {
+            "max_paragraph": 1574,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 229,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "abhinavaṭīkā",
+                        "sādhuvilāsinī",
+                        "sutta",
+                        "ṭīkā",
+                        "dīghanikāya",
+                        "sīlakkhandhavagga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 458,
+                    "book_name": "dn1_A"
+                },
+                {
+                    "para_from": 459,
+                    "para_to": 1574,
+                    "book_name": "dn1"
+                }
+            ]
+        },
+        "189": {
+            "max_paragraph": 1366,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 230,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "sādhuvilāsinī",
+                        "abhinavaṭīkā",
+                        "dīghanikāya",
+                        "sutta",
+                        "ṭīkā",
+                        "sīlakkhandhavagga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1366,
+                    "book_name": "dn1"
+                }
+            ]
+        },
+        "190": {
+            "max_paragraph": 764,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 231,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "netti",
+                        "khuddakanikāya",
+                        "ṭīkā",
+                        "sutta",
+                        "nettippakaraṇa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 764,
+                    "book_name": "kn19"
+                }
+            ]
+        },
+        "191": {
+            "max_paragraph": 1670,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 232,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "nettivibhāvinī",
+                        "ṭīkā",
+                        "sutta",
+                        "khuddakanikāya",
+                        "netti"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1670,
+                    "book_name": "kn19"
+                }
+            ]
+        },
+        "192": {
+            "max_paragraph": 2388,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 233,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "mūlapaṇṇāsa",
+                        "majjhimanikāya",
+                        "sutta",
+                        "ṭīkā",
+                        "līnatthappakāsanā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 89,
+                    "book_name": "mn1_A"
+                },
+                {
+                    "para_from": 90,
+                    "para_to": 2388,
+                    "book_name": "mn1"
+                }
+            ]
+        },
+        "193": {
+            "max_paragraph": 906,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 234,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "līnatthappakāsanā",
+                        "ṭīkā",
+                        "sutta",
+                        "majjhimanikāya",
+                        "majjhimapaṇṇāsa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 906,
+                    "book_name": "mn2"
+                }
+            ]
+        },
+        "194": {
+            "max_paragraph": 858,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 235,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "līnatthappakāsanā",
+                        "ṭīkā",
+                        "sutta",
+                        "majjhimanikāya",
+                        "uparipaṇṇāsa"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 858,
+                    "book_name": "mn3"
+                }
+            ]
+        },
+        "195": {
+            "max_paragraph": 1705,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 236,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "ṭīkā",
+                        "līnatthappakāsanā",
+                        "sagāthāvagga",
+                        "saṃyuttanikāya",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1705,
+                    "book_name": "sn1"
+                }
+            ]
+        },
+        "196": {
+            "max_paragraph": 1027,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 237,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "saṃyuttanikāya",
+                        "nidānavagga",
+                        "līnatthappakāsanā",
+                        "ṭīkā",
+                        "sutta"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 1027,
+                    "book_name": "sn2"
+                }
+            ]
+        },
+        "197": {
+            "max_paragraph": 520,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 238,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "sutta",
+                        "khandhavagga",
+                        "līnatthappakāsanā",
+                        "ṭīkā",
+                        "saṃyuttanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 520,
+                    "book_name": "sn3"
+                }
+            ]
+        },
+        "198": {
+            "max_paragraph": 729,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 239,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "sutta",
+                        "saḷāyatanavagga",
+                        "līnatthappakāsanā",
+                        "ṭīkā",
+                        "saṃyuttanikāya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 729,
+                    "book_name": "sn4"
+                }
+            ]
+        },
+        "199": {
+            "max_paragraph": 850,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 240,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "sutta",
+                        "saṃyuttanikāya",
+                        "līnatthappakāsanā",
+                        "ṭīkā",
+                        "mahāvagga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 850,
+                    "book_name": "sn5"
+                }
+            ]
+        },
+        "200": {
+            "max_paragraph": 13281,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 241,
+                    "from_paragraph": 2,
+                    "type": "tika",
+                    "tags": [
+                        "vinicchaya",
+                        "vinayavinicchaya",
+                        "vinaya",
+                        "ṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 242,
+                    "from_paragraph": 10176,
+                    "type": "tika",
+                    "tags": [
+                        "ṭīkā",
+                        "uttaravinicchaya",
+                        "vinicchaya",
+                        "vinaya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 10175,
+                    "book_name": "vin8"
+                },
+                {
+                    "para_from": 10176,
+                    "para_to": 13281,
+                    "book_name": "vin9"
+                }
+            ]
+        },
+        "201": {
+            "max_paragraph": 5124,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 243,
+                    "from_paragraph": 2,
+                    "type": "tika",
+                    "tags": [
+                        "vinayavinicchayaṭīkā",
+                        "ṭīkā",
+                        "vinaya",
+                        "vinayavinicchaya",
+                        "vinicchaya"
+                    ]
+                },
+                {
+                    "book_id": 244,
+                    "from_paragraph": 4270,
+                    "type": "tika",
+                    "tags": [
+                        "uttaravinicchayaṭīkā",
+                        "ṭīkā",
+                        "vinaya",
+                        "uttaravinicchaya",
+                        "vinicchaya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1,
+                    "book_name": "vin9"
+                },
+                {
+                    "para_from": 2,
+                    "para_to": 4269,
+                    "book_name": "vin8"
+                },
+                {
+                    "para_from": 4270,
+                    "para_to": 5124,
+                    "book_name": "vin9"
+                }
+            ]
+        },
+        "202": {
+            "max_paragraph": 2992,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 245,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "pācityādiyojanā",
+                        "yojanā",
+                        "pācittiyapāḷi",
+                        "ṭīkā",
+                        "vinaya",
+                        "suttavibhaṅga",
+                        "uttaravinicchayaṭīkā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1005,
+                    "book_name": "vin2"
+                },
+                {
+                    "para_from": 1006,
+                    "para_to": 1850,
+                    "book_name": "vin3"
+                },
+                {
+                    "para_from": 1851,
+                    "para_to": 2423,
+                    "book_name": "vin4"
+                },
+                {
+                    "para_from": 2424,
+                    "para_to": 2992,
+                    "book_name": "vin5"
+                }
+            ]
+        },
+        "203": {
+            "max_paragraph": 3631,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 246,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "vinaya",
+                        "khuddasikkhā",
+                        "ṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 247,
+                    "from_paragraph": 1620,
+                    "type": "tika",
+                    "tags": [
+                        "vinaya",
+                        "khuddasikkhā",
+                        "purāṇaṭīkā",
+                        "ṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 248,
+                    "from_paragraph": 2361,
+                    "type": "tika",
+                    "tags": [
+                        "ṭīkā",
+                        "vinaya",
+                        "abhinavaṭīkā",
+                        "khuddasikkhā"
+                    ]
+                },
+                {
+                    "book_id": 249,
+                    "from_paragraph": 3100,
+                    "type": "tika",
+                    "tags": [
+                        "vinaya",
+                        "ṭīkā",
+                        "mūlasikkhā"
+                    ]
+                },
+                {
+                    "book_id": 250,
+                    "from_paragraph": 3481,
+                    "type": "tika",
+                    "tags": [
+                        "mūlasikkhā",
+                        "ṭīkā",
+                        "vinaya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 3104,
+                    "book_name": "vin10"
+                },
+                {
+                    "para_from": 3105,
+                    "para_to": 3631,
+                    "book_name": "vin11"
+                }
+            ]
+        },
+        "204": {
+            "max_paragraph": 1659,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 251,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "paṭhamo bhāgo",
+                        "ṭīkā",
+                        "dīpanī",
+                        "vinaya",
+                        "suttavibhaṅga",
+                        "mahāvibhaṅga",
+                        "sāratthadīpanī",
+                        "pārājikapāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1659,
+                    "book_name": "vin1"
+                }
+            ]
+        },
+        "205": {
+            "max_paragraph": 1660,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 252,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "mahāvibhaṅga",
+                        "pārājikapāḷi",
+                        "sāratthadīpanī",
+                        "ṭīkā",
+                        "dīpanī",
+                        "vinaya",
+                        "dutiyo bhāgo",
+                        "suttavibhaṅga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1660,
+                    "book_name": "vin1"
+                }
+            ]
+        },
+        "206": {
+            "max_paragraph": 2401,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 253,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "dīpanī",
+                        "ṭīkā",
+                        "vinaya",
+                        "suttavibhaṅga",
+                        "mahāvibhaṅga",
+                        "pācittiyapāḷi",
+                        "sāratthadīpanī",
+                        "tatiyo bhāgo"
+                    ]
+                },
+                {
+                    "book_id": 254,
+                    "from_paragraph": 717,
+                    "type": "tika",
+                    "tags": [
+                        "sāratthadīpanī",
+                        "vinaya",
+                        "khandhaka",
+                        "mahāvagga",
+                        "ṭīkā",
+                        "dīpanī"
+                    ]
+                },
+                {
+                    "book_id": 255,
+                    "from_paragraph": 1753,
+                    "type": "tika",
+                    "tags": [
+                        "cūḷavagga",
+                        "ṭīkā",
+                        "dīpanī",
+                        "vinaya",
+                        "khandhaka",
+                        "sāratthadīpanī"
+                    ]
+                },
+                {
+                    "book_id": 256,
+                    "from_paragraph": 2154,
+                    "type": "tika",
+                    "tags": [
+                        "sāratthadīpanī",
+                        "ṭīkā",
+                        "dīpanī",
+                        "vinaya",
+                        "parivāra",
+                        "parivārapāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 716,
+                    "book_name": "vin2"
+                },
+                {
+                    "para_from": 717,
+                    "para_to": 1752,
+                    "book_name": "vin3"
+                },
+                {
+                    "para_from": 1753,
+                    "para_to": 2153,
+                    "book_name": "vin4"
+                },
+                {
+                    "para_from": 2154,
+                    "para_to": 2401,
+                    "book_name": "vin5"
+                }
+            ]
+        },
+        "207": {
+            "max_paragraph": 2713,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 257,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "dvemātikāpāḷi",
+                        "bhikkhupātimokkha",
+                        "pāḷi",
+                        "vinaya",
+                        "ṭīkā",
+                        "pātimokkha"
+                    ]
+                },
+                {
+                    "book_id": 258,
+                    "from_paragraph": 524,
+                    "type": "tika",
+                    "tags": [
+                        "dvemātikāpāḷi",
+                        "ṭīkā",
+                        "vinaya",
+                        "pāḷi",
+                        "bhikkhunī",
+                        "bhikkhupātimokkha",
+                        "pātimokkha"
+                    ]
+                },
+                {
+                    "book_id": 259,
+                    "from_paragraph": 1215,
+                    "type": "tika",
+                    "tags": [
+                        "dvemātikāpāḷi",
+                        "pātimokkha",
+                        "vinaya",
+                        "ṭīkā",
+                        "kaṅkhāvitaraṇī"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2713,
+                    "book_name": "vin6"
+                }
+            ]
+        },
+        "208": {
+            "max_paragraph": 1673,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 260,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "vinayasaṅgaha",
+                        "ṭīkā",
+                        "vinaya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1673,
+                    "book_name": "vin7"
+                }
+            ]
+        },
+        "209": {
+            "max_paragraph": 2673,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 261,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "vajirabuddhi",
+                        "vinaya",
+                        "ṭīkā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 991,
+                    "book_name": "vin1"
+                },
+                {
+                    "para_from": 992,
+                    "para_to": 1714,
+                    "book_name": "vin2"
+                },
+                {
+                    "para_from": 1715,
+                    "para_to": 2216,
+                    "book_name": "vin3"
+                },
+                {
+                    "para_from": 2217,
+                    "para_to": 2427,
+                    "book_name": "vin4"
+                },
+                {
+                    "para_from": 2428,
+                    "para_to": 2673,
+                    "book_name": "vin5"
+                }
+            ]
+        },
+        "210": {
+            "max_paragraph": 3137,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 267,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "ṭīkā",
+                        "vinaya",
+                        "suttavibhaṅga",
+                        "mahāvibhaṅga",
+                        "pārājikapāḷi",
+                        "vimativinodanī"
+                    ]
+                },
+                {
+                    "book_id": 268,
+                    "from_paragraph": 1658,
+                    "type": "tika",
+                    "tags": [
+                        "ṭīkā",
+                        "vimativinodanī",
+                        "pācittiyapāḷi",
+                        "bhikkhunī",
+                        "bhikkhunīvibhaṅga",
+                        "suttavibhaṅga",
+                        "vinaya"
+                    ]
+                },
+                {
+                    "book_id": 269,
+                    "from_paragraph": 1779,
+                    "type": "tika",
+                    "tags": [
+                        "vinaya",
+                        "mahāvagga",
+                        "khandhaka",
+                        "vimativinodanī",
+                        "ṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 270,
+                    "from_paragraph": 2431,
+                    "type": "tika",
+                    "tags": [
+                        "khandhaka",
+                        "cūḷavagga",
+                        "vimativinodanī",
+                        "ṭīkā",
+                        "vinaya"
+                    ]
+                },
+                {
+                    "book_id": 271,
+                    "from_paragraph": 2792,
+                    "type": "tika",
+                    "tags": [
+                        "vinaya",
+                        "parivārapāḷi",
+                        "parivāra",
+                        "vimativinodanī",
+                        "ṭīkā"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1182,
+                    "book_name": "vin1"
+                },
+                {
+                    "para_from": 1183,
+                    "para_to": 1778,
+                    "book_name": "vin2"
+                },
+                {
+                    "para_from": 1779,
+                    "para_to": 2430,
+                    "book_name": "vin3"
+                },
+                {
+                    "para_from": 2431,
+                    "para_to": 2791,
+                    "book_name": "vin4"
+                },
+                {
+                    "para_from": 2792,
+                    "para_to": 3137,
+                    "book_name": "vin5"
+                }
+            ]
+        },
+        "211": {
+            "max_paragraph": 2414,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 272,
+                    "from_paragraph": 3,
+                    "type": "tika",
+                    "tags": [
+                        "vinayālaṅkāra",
+                        "ṭīkā",
+                        "vinaya"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 1934,
+                    "book_name": "vin7"
+                },
+                {
+                    "para_from": 1935,
+                    "para_to": 2414,
+                    "book_name": "vin7_A"
+                }
+            ]
+        },
+        "212": {
+            "max_paragraph": 2517,
+            "name_source": "old_table",
+            "file_types": [
+                {
+                    "book_id": 273,
+                    "from_paragraph": 2,
+                    "type": "tika",
+                    "tags": [
+                        "vinaya",
+                        "pātimokkha",
+                        "kaṅkhāvitaraṇī",
+                        "ṭīkā",
+                        "purāṇaṭīkā"
+                    ]
+                },
+                {
+                    "book_id": 274,
+                    "from_paragraph": 671,
+                    "type": "tika",
+                    "tags": [
+                        "abhinavaṭīkā",
+                        "vinaya",
+                        "ṭīkā",
+                        "kaṅkhāvitaraṇī",
+                        "pātimokkha"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 2517,
+                    "book_name": "vin6"
+                }
+            ]
+        },
+        "213": {
+            "max_paragraph": 2477,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 275,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "pārājika",
+                        "vinaya",
+                        "mūla",
+                        "pāḷi",
+                        "suttavibhaṅga",
+                        "mahāvibhaṅga",
+                        "pārājikapāḷi"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 2477,
+                    "book_name": "vin1"
+                }
+            ]
+        },
+        "214": {
+            "max_paragraph": 3690,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 276,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "pācittiyapāḷi",
+                        "mūla",
+                        "vinaya",
+                        "pāḷi",
+                        "mahāvibhaṅga",
+                        "suttavibhaṅga"
+                    ]
+                },
+                {
+                    "book_id": 281,
+                    "from_paragraph": 2162,
+                    "type": "mula",
+                    "tags": [
+                        "pāḷi",
+                        "suttavibhaṅga",
+                        "pācittiyapāḷi",
+                        "bhikkhunī",
+                        "bhikkhunīvibhaṅga",
+                        "vinaya",
+                        "mūla"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 3690,
+                    "book_name": "vin2"
+                }
+            ]
+        },
+        "215": {
+            "max_paragraph": 2759,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 277,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "pāḷi",
+                        "mūla",
+                        "vinaya",
+                        "khandhaka",
+                        "mahāvagga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 2759,
+                    "book_name": "vin3"
+                }
+            ]
+        },
+        "216": {
+            "max_paragraph": 2821,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 278,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "khandhaka",
+                        "vinaya",
+                        "mūla",
+                        "pāḷi",
+                        "cūḷavagga"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 2821,
+                    "book_name": "vin4"
+                }
+            ]
+        },
+        "217": {
+            "max_paragraph": 3645,
+            "name_source": "wbw_book_tag",
+            "file_types": [
+                {
+                    "book_id": 279,
+                    "from_paragraph": 3,
+                    "type": "mula",
+                    "tags": [
+                        "pāḷi",
+                        "mūla",
+                        "vinaya",
+                        "parivāra"
+                    ]
+                }
+            ],
+            "segments": [
+                {
+                    "para_from": 3,
+                    "para_to": 3645,
+                    "book_name": "vin5"
+                }
+            ]
+        }
+    },
+    "overrides": {
+        "107": {
+            "reason": "旧表把 book 107(apadāna-aṭṭhakathā)切成 kn10_A(para 3-2141)与 kn10_B(para 2142-3875),并声称后者对应 therī。实测标题证伪:para 2146 的标题是「(Dutiyo bhāgo) Therāpadānaṃ」——那是 therāpadāna 的第二卷而非 therīapadāna;全书只有一个 level-1 标题「Apadāna-aṭṭhakathā」,vagga 编到 56(Yasavaggo)也正是 therāpadāna 的范围,书内无任何 therī 标题。therīapadāna 另在 book 144 para 6534 起(level-1「Therīapadānapāḷi」,wbw 标记 kn11@6532)。故 book 107 整本归 kn10,与 mūla 侧 books 143/144 的 kn10 对齐。",
+            "segments": [
+                {
+                    "para_from": 1,
+                    "para_to": 999999,
+                    "book_name": "kn10"
+                }
+            ]
+        }
+    }
+}

+ 264 - 0
api-v13/scripts/related/build_book_config.php

@@ -0,0 +1,264 @@
+<?php
+
+/**
+ * 生成书级配置表 book_config.json:每本 VRI 文件的类型(mūla/义注/复注)与
+ * 书名分段(哪一段段落属于哪个 book_name)。
+ *
+ * 为什么需要:123 本文件的 wbw 标记里完全没有书名信息(只有裸 paraN),书名要从
+ * 别处来。证据按可信度排序:
+ *   1. wbw 的 book_tag 标记(dn1@3、an3@323)——文件自带的书界,最硬
+ *   2. wbw 的 paraN_xxx 后缀——逐段自带书名
+ *   3. 旧表的主导书名——最后兜底。旧表的错在 cs 值不在书名上,唯一例外是
+ *      kn10_A/kn10_B,而那两本恰好有 book_tag 证据,会被 1 覆盖掉。
+ *
+ * overrides 区是人工修正区,脚本不动它,每次重跑都保留。
+ *
+ * 用法:php scripts/related/build_book_config.php [--baseline=related_paragraphs_bak_20260909]
+ */
+
+require __DIR__.'/lib/boot.php';
+
+use Illuminate\Support\Facades\DB;
+
+[$options] = rp_args($argv);
+$baseline = rp_assert_table($options['baseline'] ?? 'related_paragraphs_bak_20260909');
+$markers = $options['markers'] ?? 'rp_markers';
+$configPath = __DIR__.'/book_config.json';
+
+$log = new RpLogger('build_book_config', rp_report_dir());
+$log->info("书名兜底基线:$baseline");
+
+// 已有的人工修正区必须原样带过来,否则每次重跑都把人的判断冲掉。
+$overrides = new stdClass;
+if (file_exists($configPath)) {
+    $existing = rp_json_read($configPath)['overrides'] ?? [];
+    $overrides = $existing === [] ? new stdClass : (object) $existing;
+    $log->info('保留已有 overrides:'.count((array) $overrides).' 条');
+}
+
+// ── 文件类型:按 level-1 段落的 tag 判定,粒度是 book_id 不是 VRI 文件 ──────
+// book 207 一个文件里既有 pāḷi 又有 ṭīkā,按文件判会判错。
+$typeRows = DB::select("
+    select p.book, p.paragraph, bt.sn as book_id, string_agg(t.name, ',') as tags
+    from pali_texts p
+    join tag_maps m on m.anchor_id = p.uid and m.table_name = 'pali_texts'
+    join tags t on t.id = m.tag_id
+    left join book_titles bt on bt.book = p.book and bt.paragraph = p.paragraph
+    where p.level = 1
+    group by p.book, p.paragraph, bt.sn
+    order by p.book, p.paragraph
+");
+
+$fileTypes = [];
+foreach ($typeRows as $row) {
+    $tags = explode(',', $row->tags);
+    $type = match (true) {
+        (bool) array_intersect($tags, ['ṭīkā', 'mūlaṭīkā', 'anuṭīkā', 'abhinavaṭīkā', 'purāṇaṭīkā']) => 'tika',
+        in_array('aṭṭhakathā', $tags, true) => 'atthakatha',
+        (bool) array_intersect($tags, ['mūla', 'pāḷi']) => 'mula',
+        default => 'unknown',
+    };
+    $fileTypes[$row->book][] = [
+        'book_id' => $row->book_id,
+        'from_paragraph' => $row->paragraph,
+        'type' => $type,
+        'tags' => $tags,
+    ];
+    $log->count("file_type.$type");
+    if ($type === 'unknown') {
+        $log->unresolved('file_type_unknown', [
+            'book' => $row->book, 'paragraph' => $row->paragraph, 'tags' => $row->tags,
+        ]);
+    }
+}
+
+// ── 书名证据 ─────────────────────────────────────────────────────────────
+$books = DB::select('select distinct book from pali_texts order by book');
+$config = [];
+
+foreach ($books as $b) {
+    $book = $b->book;
+    $maxPara = (int) DB::table('pali_texts')->where('book', $book)->max('paragraph');
+
+    // 证据 1:book_tag 标记,给出文件内的书界切换点
+    $tagMarks = DB::select(
+        "select paragraph, book_name from $markers where book = ? and kind = 'book_tag' order by paragraph",
+        [$book]
+    );
+
+    // 证据 2:paraN_xxx 后缀,逐段自带书名
+    $suffixMarks = DB::select(
+        "select paragraph, book_name from $markers
+         where book = ? and kind in ('single','interval') and book_name is not null
+         order by paragraph, wid",
+        [$book]
+    );
+
+    // 证据 3:旧表逐段落的名字,按连续段压缩。
+    // 空书名也必须成段——book 177 一个文件里装了四部书,只有头一部是 abhi8,
+    // 后三部(nāmarūpaparicchedo 等)本来就没有 SC 书名。早先过滤掉空名,
+    // 结果 abhi8 越过书界铺满全文件,把 10695 行错配进了 abhi8 的注释链。
+    $oldNames = DB::select(
+        "select para, max(book_name) as book_name from $baseline where book = ?
+         group by para order by para",
+        [$book]
+    );
+
+    $source = match (true) {
+        $tagMarks !== [] => 'wbw_book_tag',
+        $suffixMarks !== [] => 'wbw_suffix',
+        $oldNames !== [] => 'old_table',
+        default => 'none',
+    };
+
+    $points = [];   // paragraph => book_name 的切换点
+    if ($source === 'wbw_book_tag') {
+        foreach ($tagMarks as $m) {
+            $points[(int) $m->paragraph] = $m->book_name;
+        }
+        // book_tag 只给书界,段内若还有后缀且与之矛盾,记下来复核
+        foreach ($suffixMarks as $m) {
+            $expected = null;
+            foreach ($points as $p => $n) {
+                if ($p <= $m->paragraph) {
+                    $expected = $n;
+                }
+            }
+            if ($expected !== null && $expected !== $m->book_name) {
+                $log->count('name_conflict.tag_vs_suffix');
+                $log->unresolved('name_conflict_tag_vs_suffix', [
+                    'book' => $book, 'paragraph' => (int) $m->paragraph,
+                    'book_tag_says' => $expected, 'suffix_says' => $m->book_name,
+                ]);
+            }
+        }
+    } elseif ($source === 'wbw_suffix') {
+        $prev = null;
+        foreach ($suffixMarks as $m) {
+            if ($m->book_name !== $prev) {
+                $points[(int) $m->paragraph] = $m->book_name;
+                $prev = $m->book_name;
+            }
+        }
+    } elseif ($source === 'old_table') {
+        $prev = null;
+        foreach ($oldNames as $m) {
+            // 序言名 dn1_A 是义注开头的正常设计,保留;其余同名连续段合并
+            if ($m->book_name !== $prev) {
+                $points[(int) $m->para] = $m->book_name;
+                $prev = $m->book_name;
+            }
+        }
+    }
+
+    // 义注/复注开头的序言段用 {book}_A 命名(dn1_A 型,全库 12 本)。wbw 里带
+    // {mula}_0 章标记的书(99/103/130/133)导出器能自己认出来,但另有 6 本
+    // (181/185/188/192 及 179/211 的文件中段)wbw 完全没有痕迹,序言范围只存在于
+    // 旧表里。这里把旧表的 _A 连续段叠加进切换点,免得序言被并进正文名下。
+    if ($source !== 'old_table') {
+        // 注意:序言段里出现 paraN_mula 标记是正常的,不能拿首个 cs 标记当序言终点。
+        // book 130 的序言(mn1_0@5)里就有 para1_mn1@40,序言真正的终点是章标记
+        // mn1_1@47;book 192 同样。序言段照旧表原样叠加即可。
+        $runName = null;
+        $runFrom = null;
+        $flushRun = function (int $endPara) use (&$points, &$runName, &$runFrom, $log, $book, $oldNames) {
+            if ($runName === null) {
+                return;
+            }
+            $base = preg_replace('/_A$/', '', $runName);
+
+            // 真序言的后面一定跟着正文(基名)。book 143 的 kn10_A 铺满整个文件、
+            // 后面根本没有 kn10——那是 E3 的错名而不是序言,绝不能叠加进来。
+            $baseFollows = false;
+            foreach ($oldNames as $row) {
+                if ((int) $row->para > $endPara && $row->book_name === $base) {
+                    $baseFollows = true;
+                    break;
+                }
+            }
+            if (! $baseFollows) {
+                $log->count('prologue_overlay.rejected');
+                $log->info("book $book 拒绝叠加 $runName@$runFrom-{$endPara}:其后无 $base 正文,判为错名而非序言");
+                $runName = null;
+                $runFrom = null;
+
+                return;
+            }
+            // 序言区间内的旧切换点要清掉:book_tag 的 dn1@3 落在 dn1_A@1-198 中间,
+            // 不清就会把序言从 para 3 截断,只剩两段。
+            $base = $points[$endPara + 1] ?? $base;
+            foreach (array_keys($points) as $existing) {
+                if ($existing > $runFrom && $existing <= $endPara) {
+                    unset($points[$existing]);
+                }
+            }
+            $points[$runFrom] = $runName;
+            if (! isset($points[$endPara + 1])) {
+                $points[$endPara + 1] = $base;
+            }
+            $log->count('prologue_overlay');
+            $log->info("book $book 叠加序言段 $runName@$runFrom-$endPara");
+            $runName = null;
+            $runFrom = null;
+        };
+        $lastPara = null;
+        foreach ($oldNames as $m) {
+            $para = (int) $m->para;
+            $isPrologue = str_ends_with((string) $m->book_name, '_A');
+            if ($isPrologue && $m->book_name === $runName && $para === $lastPara + 1) {
+                $lastPara = $para;
+
+                continue;
+            }
+            if ($runName !== null) {
+                $flushRun($lastPara);
+            }
+            if ($isPrologue) {
+                $runName = $m->book_name;
+                $runFrom = $para;
+            }
+            $lastPara = $para;
+        }
+        if ($runName !== null) {
+            $flushRun($lastPara);
+        }
+        ksort($points);
+    }
+
+    // 切换点 → 区间
+    $segments = [];
+    $keys = array_keys($points);
+    foreach ($keys as $i => $from) {
+        $segments[] = [
+            'para_from' => $from,
+            'para_to' => isset($keys[$i + 1]) ? $keys[$i + 1] - 1 : $maxPara,
+            'book_name' => $points[$from],
+        ];
+    }
+
+    $config[(string) $book] = [
+        'max_paragraph' => $maxPara,
+        'name_source' => $source,
+        'file_types' => $fileTypes[$book] ?? [],
+        'segments' => $segments,
+    ];
+    $log->count("name_source.$source");
+    if ($source === 'none') {
+        $log->unresolved('book_name_unknown', ['book' => $book]);
+    }
+    if (count($segments) > 1) {
+        $log->info(sprintf('book %-3d %-14s %d 段:%s', $book, $source, count($segments),
+            implode(' ', array_map(fn ($s) => "{$s['book_name']}@{$s['para_from']}", $segments))));
+    }
+}
+
+rp_json_write($configPath, [
+    'generated_at' => date('c'),
+    'baseline_table' => $baseline,
+    'purpose' => '每本 VRI 文件的类型与书名分段。导出器优先用 wbw 现场证据,此表只在文件本身没有书名信息时兜底。overrides 是人工修正区,重跑不覆盖。',
+    'books' => $config,
+    'overrides' => $overrides,
+]);
+$log->info("配置表:{$configPath}(".count($config).' 本)');
+
+$log->finish();

+ 129 - 0
api-v13/scripts/related/build_markers.php

@@ -0,0 +1,129 @@
+<?php
+
+/**
+ * 把 wbw_templates 里的 .ctl. 锚点解析成工作表 rp_markers。
+ *
+ * 导出器和检测器都要按标记做集合运算,逐次在 PHP 里重解析 33 万行既慢又容易两边
+ * 规则跑偏,所以物化一次、共用一份。改了 MarkerParser 就要重跑这个脚本。
+ *
+ * 用法:php scripts/related/build_markers.php [--table=rp_markers]
+ */
+
+require __DIR__.'/lib/boot.php';
+require __DIR__.'/lib/MarkerParser.php';
+
+use Illuminate\Support\Facades\DB;
+
+[$options] = rp_args($argv);
+$table = $options['table'] ?? 'rp_markers';
+if (! preg_match('/^rp_[a-z0-9_]+$/', $table)) {
+    throw new InvalidArgumentException("工作表名必须以 rp_ 开头:$table");
+}
+
+$log = new RpLogger('build_markers', rp_report_dir());
+$log->info("目标工作表:$table");
+
+DB::statement("drop table if exists $table");
+DB::statement("
+    create table $table (
+        book        integer not null,
+        paragraph   integer not null,
+        wid         integer not null,
+        word        varchar(1024) not null,
+        kind        varchar(16) not null,
+        book_name   varchar(64),
+        cs_from     integer,
+        cs_to       integer,
+        cs_count    integer not null default 0,
+        numbers     varchar(64),
+        is_abbrev   boolean not null default false,
+        repaired    text,
+        error       text
+    )
+");
+
+$total = DB::table('wbw_templates')->where('type', '.ctl.')->where('style', '#a#')->count();
+$log->info("待解析锚点:$total 行");
+
+$buffer = [];
+$written = 0;
+
+$flush = function () use (&$buffer, &$written, $table, $log) {
+    if ($buffer === []) {
+        return;
+    }
+    DB::table($table)->insert($buffer);
+    $written += count($buffer);
+    $buffer = [];
+    if ($written % 50000 === 0) {
+        $log->info("  已写入 $written 行");
+    }
+};
+
+DB::table('wbw_templates')
+    ->where('type', '.ctl.')
+    ->where('style', '#a#')
+    ->select('book', 'paragraph', 'wid', 'word')
+    ->orderBy('book')->orderBy('paragraph')->orderBy('wid')
+    ->chunk(20000, function ($chunk) use (&$buffer, $flush, $log) {
+        foreach ($chunk as $row) {
+            $parsed = MarkerParser::parse($row->word);
+            $log->count('kind.'.$parsed['kind']);
+
+            // 缩写区间:右端位数少于左端,正是旧生成器翻车的那一类,单独打标以便统计。
+            $isAbbrev = false;
+            if ($parsed['kind'] === MarkerParser::KIND_INTERVAL
+                && preg_match('/^para([0-9]+)-([0-9]+)/', $row->word, $m)
+                && strlen($m[2]) < strlen($m[1])) {
+                $isAbbrev = true;
+                $log->count('interval.abbrev');
+            }
+
+            if ($parsed['error']) {
+                $log->unresolved('marker_parse_failed', [
+                    'book' => $row->book, 'paragraph' => $row->paragraph,
+                    'word' => $row->word, 'detail' => $parsed['error'],
+                ]);
+            }
+            if ($parsed['repaired']) {
+                $log->warn("修补标记 book={$row->book} para={$row->paragraph}: {$parsed['repaired']}");
+                $log->unresolved('marker_repaired', [
+                    'book' => $row->book, 'paragraph' => $row->paragraph,
+                    'word' => $row->word, 'detail' => $parsed['repaired'],
+                ]);
+            }
+
+            $buffer[] = [
+                'book' => $row->book,
+                'paragraph' => $row->paragraph,
+                'wid' => $row->wid,
+                'word' => $row->word,
+                'kind' => $parsed['kind'],
+                'book_name' => $parsed['book_name'],
+                'cs_from' => $parsed['cs'] === [] ? null : $parsed['cs'][0],
+                'cs_to' => $parsed['cs'] === [] ? null : end($parsed['cs']),
+                'cs_count' => count($parsed['cs']),
+                'numbers' => $parsed['numbers'] ? implode('.', $parsed['numbers']) : null,
+                'is_abbrev' => $isAbbrev,
+                'repaired' => $parsed['repaired'],
+                'error' => $parsed['error'],
+            ];
+            if (count($buffer) >= 5000) {
+                $flush();
+            }
+        }
+    });
+$flush();
+
+$log->info("写入完成:$written 行");
+if ($written !== $total) {
+    $log->error("行数不符:源 $total vs 写入 $written");
+}
+
+$log->info('建索引中…');
+DB::statement("create index on $table (book, paragraph)");
+DB::statement("create index on $table (kind)");
+DB::statement("create index on $table (book_name)");
+DB::statement("create index on $table (is_abbrev) where is_abbrev");
+
+$log->finish();

+ 385 - 0
api-v13/scripts/related/check.php

@@ -0,0 +1,385 @@
+<?php
+
+/**
+ * related_paragraphs 记分卡:按 known_issues.json 的探测器给任意一张 related 表打分。
+ *
+ * 这是整个重建工程的标尺——每一轮「导出 → 检测 → 交给模型评估 → 改规则」都靠它
+ * 回答两个问题:错误变少了没有?本来正确的书有没有被搞坏?
+ *
+ * 用法:
+ *   php scripts/related/check.php --table=related_paragraphs_bak_20260909 --save-baseline
+ *   php scripts/related/check.php --table=related_paragraphs_v1
+ *
+ * 依赖 rp_markers(先跑 build_markers.php)。
+ */
+
+require __DIR__.'/lib/boot.php';
+
+use Illuminate\Support\Facades\DB;
+
+[$options] = rp_args($argv);
+$table = rp_assert_table($options['table'] ?? 'related_paragraphs');
+$markers = $options['markers'] ?? 'rp_markers';
+$saveBaseline = isset($options['save-baseline']);
+
+$known = rp_json_read(__DIR__.'/known_issues.json');
+$params = $known['params'];
+$log = new RpLogger('check.'.$table, rp_report_dir());
+$log->info("被检表:$table");
+$log->info("标记表:$markers");
+
+$one = fn (string $sql, array $bind = []) => (array) DB::selectOne($sql, $bind);
+$all = fn (string $sql, array $bind = []) => array_map(fn ($r) => (array) $r, DB::select($sql, $bind));
+
+$results = [];
+
+/** 记一条探测结果,并与 known_issues.json 的基线对照。 */
+$record = function (string $id, array $measured, ?string $note = null) use (&$results, $known, $log) {
+    $spec = null;
+    foreach ($known['detectors'] as $d) {
+        if ($d['id'] === $id) {
+            $spec = $d;
+            break;
+        }
+    }
+    $results[$id] = [
+        'title' => $spec['title'] ?? $id,
+        'severity' => $spec['severity'] ?? 'unknown',
+        'baseline' => $spec['baseline'] ?? null,
+        'target_v1' => $spec['target_v1'] ?? null,
+        'measured' => $measured,
+        'note' => $note,
+    ];
+    $log->info(sprintf('[%-24s] %s', $id, json_encode($measured, JSON_UNESCAPED_UNICODE)));
+    if ($note) {
+        $log->info('  '.$note);
+    }
+};
+
+// ── E1 cs 乱码值 ──────────────────────────────────────────────────────────
+$threshold = $params['cs_garbage_threshold'];
+$record('E1_cs_garbage', $one(
+    "select count(*)::int as rows, count(distinct book)::int as books from $table where cs_para > ?",
+    [$threshold]
+));
+
+// ── E2 章回绕:同 (book, book_name) 内 cs 从大值回到 1 ────────────────────
+// 必须按段落级 cs_min 判,不能逐行比。区间标记展开后一个段落有多行(para1-2 展开
+// 成 cs=1、cs=2 两行),连续这样的段落逐行看就是 …2,1,2,1… ——每段都像一次重置。
+// book 137 的 sn5「5 次回绕」、book 108 的 kn12「95 次回绕」都是这么来的假象。
+$record('E2_chapter_wrap', $one("
+    with p as (
+        select book, book_name, para, min(cs_para) as cs_min
+        from {$table} where cs_para > 0 and book_name <> ''
+        group by book, book_name, para
+    ),
+    s as (
+        select book, book_name, para, cs_min,
+               lag(cs_min) over (partition by book, book_name order by para) as prev_min
+        from p
+    )
+    select count(*)::int as wrap_points, count(distinct book)::int as books
+    from s where cs_min = 1 and prev_min > 1
+"));
+
+// ── E3 kn10 家族 ─────────────────────────────────────────────────────────
+$record('E3_kn10_missing', $one("
+    select
+        count(*) filter (where book_name = 'kn10')::int   as kn10_rows,
+        count(*) filter (where book_name = 'kn10_A')::int as kn10_a_rows,
+        count(*) filter (where book_name = 'kn10_B')::int as kn10_b_rows
+    from $table
+"));
+
+// ── E4 空 book_name(预期保留) ───────────────────────────────────────────
+$record('E4_empty_book_name', $one(
+    "select count(*)::int as rows, count(distinct book)::int as books from $table where book_name = ''"
+));
+
+// ── E5 book_id 未赋值 ────────────────────────────────────────────────────
+$record('E5_book_id_zero', $one(
+    "select count(*)::int as rows, count(distinct book)::int as books from $table where book_id = 0"
+));
+
+// ── E6 覆盖缺口:wbw 有这个段落,related 没有任何行 ────────────────────────
+$record('E6_coverage_gap', $one("
+    with wbw as (select distinct book, paragraph from pali_texts),
+         rel as (select distinct book, para from $table)
+    select count(*)::int as segments, count(distinct w.book)::int as books
+    from wbw w left join rel r on r.book = w.book and r.para = w.paragraph
+    where r.book is null
+"));
+
+// ── E7 缩写区间:旧生成器在这里丢段或产废值 ────────────────────────────────
+$record('E7_abbrev_interval_lost', $one("
+    with seg as (select distinct book, paragraph from $markers where is_abbrev),
+         hit as (
+            select s.book, s.paragraph,
+                   count(r.id)::int as rows,
+                   count(r.id) filter (where r.cs_para > $threshold)::int as garbage
+            from seg s left join $table r on r.book = s.book and r.para = s.paragraph
+            group by 1, 2
+         )
+    select
+        count(*)::int as marker_segments,
+        count(distinct book)::int as books,
+        count(*) filter (where rows = 0)::int as segments_without_rows,
+        count(*) filter (where rows > 0 and garbage > 0)::int as segments_with_garbage
+    from hit
+"));
+
+// ── E8 书边界污染平铺 ─────────────────────────────────────────────────────
+// 污染的准确签名是「整段一个 cs 标记都没有,却被写进了非零 cs」——那个值只能是
+// 从上一本沿用下来的。早先按「cs 值种类少、行数多」判会把章回绕误判成污染
+// (book 152 有 516 个标记却只 24 个 cs 值,那是 22 次回绕),按标记判才分得开。
+// cs 恒为 0 的整段不算污染:0 是「未赋值」,不是编造出来的值。
+$flat = $all("
+    with seg as (
+        select bt.book, bt.sn as book_id, bt.paragraph as p_from,
+               coalesce(lead(bt.paragraph) over (partition by bt.book order by bt.paragraph) - 1, 2147483647) as p_to
+        from book_titles bt
+    ),
+    book_marks as (
+        select book, count(*) as n from $markers where kind in ('single','interval') group by 1
+    )
+    select s.book, s.book_id, s.p_from, x.rows, x.max_cs, x.vals
+    from seg s
+    join book_marks bm on bm.book = s.book
+    cross join lateral (
+        select count(*)::int as rows, coalesce(max(cs_para), 0)::int as max_cs,
+               count(distinct cs_para)::int as vals
+        from $table r where r.book = s.book and r.para between s.p_from and s.p_to
+    ) x
+    where x.rows > 0 and x.max_cs > 0
+      and not exists (
+        select 1 from $markers m
+        where m.book = s.book and m.paragraph between s.p_from and s.p_to
+          and m.kind in ('single','interval')
+      )
+    order by x.rows desc
+");
+$record('E8_flat_pollution', [
+    'groups' => count($flat),
+    'rows' => array_sum(array_column($flat, 'rows')),
+], $flat === [] ? null : '污染段:'.json_encode($flat, JSON_UNESCAPED_UNICODE));
+
+// ── E9 全书无标记:related 有行但 wbw 无 para 标记 ─────────────────────────
+$noMarker = $all("
+    with rel as (select book, count(*)::int as rows from $table group by 1),
+         mk  as (select book, count(*)::int as markers from $markers
+                 where kind in ('single', 'interval') group by 1)
+    select rel.book, rel.rows from rel left join mk on mk.book = rel.book
+    where coalesce(mk.markers, 0) = 0 order by rel.book
+");
+$record('E9_no_marker_books', [
+    'books' => count($noMarker),
+    'book_list' => array_column($noMarker, 'book'),
+], $noMarker === [] ? null : '各本行数:'.json_encode($noMarker, JSON_UNESCAPED_UNICODE));
+
+// ── E10 自造名(预期保留) ────────────────────────────────────────────────
+$record('E10_selfmade_names', $one("
+    select
+        count(*) filter (where book_name = 'NK')::int   as nk_rows,
+        count(*) filter (where book_name like 'subo%')::int as subo_rows,
+        count(*) filter (where book_name = 'dict')::int as dict_rows
+    from $table
+"));
+
+// ── D 连续性:同 (book, book_name, cs_para) 的 para 应构成连续区间 ──────────
+$record('D_continuity', $one("
+    with g as (
+        select book, book_name, cs_para, para,
+               para - row_number() over (partition by book, book_name, cs_para order by para) as grp
+        from $table where cs_para > 0 and book_name <> ''
+    ), seg as (
+        select book, book_name, cs_para, count(distinct grp)::int as segments
+        from g group by 1, 2, 3
+    )
+    select count(*)::int as broken_groups, count(distinct book)::int as books,
+           coalesce(sum(segments - 1), 0)::int as extra_segments
+    from seg where segments > 1
+"));
+
+// ── D 镜像对齐:注释链条两侧同名书的 cs 上界应一致 ─────────────────────────
+$watch = ['dn1', 'kn6', 'kn9', 'abhi5', 'abhi3', 'abhi4', 'abhi6', 'abhi7'];
+$mirror = [];
+foreach ($watch as $name) {
+    // 必须排除乱码值:旧表里 abhi4/5/6/7 的 max 全是六位废值(44022 等),
+    // 不滤掉就看不出真实的跨侧对齐情况。
+    $perFile = $all(
+        "select book, max(cs_para)::int as max_cs, count(*)::int as rows
+         from $table where book_name = ? and cs_para <= ? group by 1 order by 1",
+        [$name, $threshold]
+    );
+    $mirror[$name] = [
+        'overall_max' => $perFile === [] ? null : max(array_column($perFile, 'max_cs')),
+        'per_file' => $perFile,
+    ];
+}
+$aligned = $known['detectors'];
+$expectedMax = [];
+foreach ($aligned as $d) {
+    if ($d['id'] === 'D_mirror_alignment') {
+        $expectedMax = $d['baseline']['aligned'];
+    }
+}
+$deviations = [];
+foreach ($expectedMax as $name => $expected) {
+    if (($mirror[$name]['overall_max'] ?? null) !== $expected) {
+        $deviations[$name] = ['expected' => $expected, 'actual' => $mirror[$name]['overall_max'] ?? null];
+    }
+}
+$record('D_mirror_alignment', [
+    'deviations' => count($deviations),
+    'detail' => $deviations,
+], '各名 max(cs_para):'.json_encode(
+    array_map(fn ($m) => $m['overall_max'], $mirror),
+    JSON_UNESCAPED_UNICODE
+));
+$results['D_mirror_alignment']['per_file'] = $mirror;
+
+// ── D 区间展开:区间标记的展开行数必须等于 cs_to − cs_from + 1 ──────────────
+$record('D_interval_expansion', $one("
+    with mk as (
+        select book, paragraph, min(cs_from)::int as cs_from, max(cs_to)::int as cs_to,
+               max(cs_to) - min(cs_from) + 1 as expect
+        from $markers where kind = 'interval' group by 1, 2
+    ), got as (
+        select mk.book, mk.paragraph, mk.expect,
+               count(distinct r.cs_para)::int as actual
+        from mk left join $table r on r.book = mk.book and r.para = mk.paragraph
+             and r.cs_para between mk.cs_from and mk.cs_to
+        group by 1, 2, 3
+    )
+    select count(*)::int as interval_markers,
+           count(*) filter (where actual <> expect)::int as mismatched_markers,
+           count(*) filter (where actual = 0)::int as empty_markers
+    from got
+"));
+
+// ── D 配对质量:章后缀方案到底有没有帮上忙,只有这把尺子能回答 ────────────
+// index() 拿 (book_name, cs_para) 全表取行。回绕让同一个 key 在几十个章里重复,
+// 一次查询就把几十章的段落全捞回来——召回没问题,精度崩了。所以要同时看两头:
+//   可信配对:key 跨文件命中、且在任何单本书里都不超过 10 段——这才是真配对。
+//     直接数 rows_paired 会把回绕造成的噪声算成资产(kn10:1 在 56 个章里重复,
+//     一次查询捞回上千段,那不是配对)。加对章后缀时可信配对会涨,加错才会掉。
+//   膨胀键:某本书里命中超过 10 / 30 段的 key,回绕的直接症状
+$record('D_pairing', $one("
+    with per_book as (
+        select book_name, cs_para, book, count(distinct para)::int as paras
+        from {$table} where cs_para > 0 and book_name <> ''
+        group by book_name, cs_para, book
+    ),
+    k as (
+        select book_name, cs_para, count(*)::int as books,
+               sum(paras)::int as rows, max(paras)::int as max_paras
+        from per_book group by book_name, cs_para
+    )
+    select
+        count(*)::int                                            as keys,
+        count(*) filter (where books >= 2)::int                  as keys_cross_book,
+        sum(rows) filter (where books >= 2)::int                 as rows_paired,
+        sum(rows) filter (where books >= 2 and max_paras <= 10)::int as rows_paired_trusted,
+        sum(rows)::int                                           as rows_total,
+        round(avg(books), 3)::float                              as mean_books_per_key,
+        count(*) filter (where max_paras > 10)::int              as keys_bloated,
+        count(*) filter (where max_paras > 30)::int              as keys_bloated_bad,
+        max(max_paras)::int                                      as worst_paras_in_one_book
+    from k
+"));
+
+// ── 逐本记分卡:把 book 级错误信号汇总,零信号的书即基线冻结对象 ─────────────
+$log->info('生成逐本记分卡…');
+$scorecard = $all("
+    with rel as (
+        select book,
+               count(*)::int as rows,
+               count(distinct book_name)::int as names,
+               count(*) filter (where cs_para > $threshold)::int as garbage_rows,
+               count(*) filter (where book_id = 0)::int as no_book_id_rows,
+               count(*) filter (where book_name = '')::int as empty_name_rows
+        from $table group by 1
+    ),
+    wrap as (
+        select book, count(*)::int as wrap_points from (
+            select book, book_name, para, min(cs_para) as cs_min,
+                   lag(min(cs_para)) over (partition by book, book_name order by para) as prev_min
+            from {$table} where cs_para > 0 and book_name <> ''
+            group by book, book_name, para
+        ) s where cs_min = 1 and prev_min > 1 group by 1
+    ),
+    abbrev as (
+        select s.book, count(*) filter (where h.rows = 0)::int as abbrev_lost from (
+            select distinct book, paragraph from $markers where is_abbrev
+        ) s join lateral (
+            select count(r.id)::int as rows from $table r where r.book = s.book and r.para = s.paragraph
+        ) h on true group by 1
+    ),
+    gap as (
+        select w.book, count(*)::int as gap_segments
+        from (select distinct book, paragraph from wbw_templates) w
+        left join (select distinct book, para from $table) r on r.book = w.book and r.para = w.paragraph
+        where r.book is null group by 1
+    )
+    select rel.book, rel.rows, rel.names, rel.garbage_rows, rel.no_book_id_rows, rel.empty_name_rows,
+           coalesce(wrap.wrap_points, 0) as wrap_points,
+           coalesce(abbrev.abbrev_lost, 0) as abbrev_lost,
+           coalesce(gap.gap_segments, 0) as gap_segments
+    from rel
+    left join wrap on wrap.book = rel.book
+    left join abbrev on abbrev.book = rel.book
+    left join gap on gap.book = rel.book
+    order by rel.book
+");
+
+$flatBooks = [];
+foreach ($flat as $f) {
+    $flatBooks[$f['book']] = ($flatBooks[$f['book']] ?? 0) + 1;
+}
+$clean = [];
+$dirty = [];
+foreach ($scorecard as &$row) {
+    $row['flat_groups'] = $flatBooks[$row['book']] ?? 0;
+    // 空 book_name 是 E4 的预期状态,不算错误信号;其余四类算。
+    $row['signals'] = $row['garbage_rows'] + $row['wrap_points']
+        + $row['abbrev_lost'] + $row['flat_groups'] + $row['gap_segments'];
+    if ($row['signals'] === 0) {
+        $clean[] = $row['book'];
+    } else {
+        $dirty[] = $row['book'];
+    }
+}
+unset($row);
+
+$log->info(sprintf('逐本记分:合格 %d 本,有信号 %d 本,共 %d 本',
+    count($clean), count($dirty), count($scorecard)));
+
+$summary = [
+    'checked_at' => date('c'),
+    'database' => DB::connection()->getDatabaseName(),
+    'table' => $table,
+    'markers_table' => $markers,
+    'total_rows' => DB::table($table)->count(),
+    'detectors' => $results,
+    'books' => ['clean' => $clean, 'dirty' => $dirty, 'scorecard' => $scorecard],
+];
+
+$reportPath = rp_report_dir()."/check.$table.json";
+rp_json_write($reportPath, $summary);
+$log->info("报告:$reportPath");
+
+if ($saveBaseline) {
+    $baselinePath = __DIR__.'/baseline.json';
+    rp_json_write($baselinePath, [
+        'frozen_at' => date('c'),
+        'source_table' => $table,
+        'database' => DB::connection()->getDatabaseName(),
+        'purpose' => '旧表的实测基线。clean_books 是零错误信号的书,重建后这些书一本都不许掉出来;detectors 是各探测器的旧表读数,用于回答「错误是否减少」。',
+        'clean_books' => $clean,
+        'detectors' => array_map(fn ($r) => $r['measured'], $results),
+        'scorecard' => $scorecard,
+    ]);
+    $log->info("基线已冻结:$baselinePath");
+}
+
+$log->finish();

+ 133 - 0
api-v13/scripts/related/diff.php

@@ -0,0 +1,133 @@
+<?php
+
+/**
+ * 两张 related 表的逐段落差异报告。
+ *
+ * 迭代的收敛判据是「每一条 diff 都能归因」——要么是修好了,要么是已知留空,不允许
+ * 有说不清来源的差异。所以这里不只给总数,还按类型分桶并各留样本,供逐轮核对。
+ *
+ * 用法:
+ *   php scripts/related/diff.php --old=related_paragraphs_bak_20260909 --new=related_paragraphs_v1
+ *   php scripts/related/diff.php --new=related_paragraphs_v1 --book=98 --samples=40
+ */
+
+require __DIR__.'/lib/boot.php';
+
+use Illuminate\Support\Facades\DB;
+
+[$options] = rp_args($argv);
+$old = rp_assert_table($options['old'] ?? 'related_paragraphs_bak_20260909');
+$new = rp_assert_table($options['new'] ?? 'related_paragraphs_v1');
+$sampleLimit = (int) ($options['samples'] ?? 15);
+$onlyBooks = isset($options['book']) ? array_map('intval', explode(',', $options['book'])) : null;
+
+$log = new RpLogger('diff', rp_report_dir());
+$log->info("旧:$old");
+$log->info("新:$new");
+
+$bookFilter = $onlyBooks ? 'and book in ('.implode(',', $onlyBooks).')' : '';
+$log->info('比对范围:'.($onlyBooks ? implode(',', $onlyBooks) : '全库'));
+
+// 每个 (book, para) 压成一行「书名:cs 列表」的签名,逐段落比对。
+$sql = "
+    with o as (
+        select book, para,
+               string_agg(distinct book_name || ':' || cs_para, ',' order by book_name || ':' || cs_para) as sig,
+               count(*) as rows
+        from $old where true $bookFilter group by book, para
+    ),
+    n as (
+        select book, para,
+               string_agg(distinct book_name || ':' || cs_para, ',' order by book_name || ':' || cs_para) as sig,
+               count(*) as rows
+        from $new where true $bookFilter group by book, para
+    )
+    select
+        coalesce(o.book, n.book) as book,
+        coalesce(o.para, n.para) as para,
+        o.sig as old_sig, n.sig as new_sig,
+        coalesce(o.rows, 0) as old_rows, coalesce(n.rows, 0) as new_rows,
+        case
+            when o.book is null then 'only_in_new'
+            when n.book is null then 'only_in_old'
+            when o.sig = n.sig then 'same'
+            else 'changed'
+        end as verdict
+    from o full outer join n on n.book = o.book and n.para = o.para
+";
+
+$log->info('比对中…');
+$buckets = [];
+$samples = [];
+$perBook = [];
+$total = 0;
+
+foreach (DB::cursor($sql) as $row) {
+    $total++;
+    $verdict = $row->verdict;
+
+    if ($verdict === 'changed') {
+        // 细分:只是 cs 变了,还是书名也变了
+        $oldNames = [];
+        $newNames = [];
+        foreach (explode(',', (string) $row->old_sig) as $pair) {
+            $oldNames[substr($pair, 0, strrpos($pair, ':'))] = true;
+        }
+        foreach (explode(',', (string) $row->new_sig) as $pair) {
+            $newNames[substr($pair, 0, strrpos($pair, ':'))] = true;
+        }
+        $verdict = array_keys($oldNames) === array_keys($newNames) ? 'cs_changed' : 'name_changed';
+    }
+
+    $buckets[$verdict] = ($buckets[$verdict] ?? 0) + 1;
+    if ($verdict !== 'same') {
+        $perBook[$row->book][$verdict] = ($perBook[$row->book][$verdict] ?? 0) + 1;
+        if (count($samples[$verdict] ?? []) < $sampleLimit) {
+            $samples[$verdict][] = [
+                'book' => (int) $row->book, 'para' => (int) $row->para,
+                'old' => $row->old_sig, 'new' => $row->new_sig,
+            ];
+        }
+    }
+}
+
+ksort($buckets);
+$log->info('---- 差异分桶(按段落计) ----');
+foreach ($buckets as $verdict => $n) {
+    $log->info(sprintf('  %-14s %8d  %5.1f%%', $verdict, $n, $total ? $n / $total * 100 : 0));
+}
+
+foreach ($samples as $verdict => $items) {
+    $log->info("---- $verdict 样本 ----");
+    foreach ($items as $s) {
+        $log->info(sprintf('  book %-3d para %-6d 旧[%s] → 新[%s]',
+            $s['book'], $s['para'], $s['old'] ?? '—', $s['new'] ?? '—'));
+    }
+}
+
+// 差异最集中的书优先看
+$ranked = [];
+foreach ($perBook as $book => $b) {
+    $ranked[] = ['book' => (int) $book, 'total' => array_sum($b)] + $b;
+}
+usort($ranked, fn ($a, $b) => $b['total'] <=> $a['total']);
+
+$log->info('---- 差异最多的 20 本 ----');
+foreach (array_slice($ranked, 0, 20) as $r) {
+    $log->info(sprintf('  book %-3d 共 %-6d  %s', $r['book'], $r['total'],
+        json_encode(array_diff_key($r, ['book' => 1, 'total' => 1]), JSON_UNESCAPED_UNICODE)));
+}
+
+$path = rp_report_dir()."/diff.$new.json";
+rp_json_write($path, [
+    'generated_at' => date('c'),
+    'old_table' => $old, 'new_table' => $new,
+    'scope' => $onlyBooks ?? 'all',
+    'paragraphs_compared' => $total,
+    'buckets' => $buckets,
+    'per_book' => $ranked,
+    'samples' => $samples,
+]);
+$log->info("报告:$path");
+
+$log->finish();

+ 316 - 0
api-v13/scripts/related/export.php

@@ -0,0 +1,316 @@
+<?php
+
+/**
+ * 从 wbw_templates 的锚点标记重放 related_paragraphs 第一版。
+ *
+ * 原则是「查不到优于查到错」:任何一处判不定就留空、记 issue,绝不沿用上一个 cs 值。
+ * 旧生成链路(VRI html → cs6_para.csv → laravel import)翻车的地方全在这条原则上:
+ * 换书后无标记就把上一个 cs 一路铺下去(E8),缩写区间不认识就跳过或写成六位废值
+ * (E1/E7),倒序区间 begin>end 让导入的 for 循环空转(book 103 para 1464 的成因)。
+ *
+ * 本版不处理章回绕后缀(E2),那要看标题层级,留给下一批;先把确定性规则跑干净,
+ * 混在一起会看不出是哪条规则错了。
+ *
+ * 用法:
+ *   php scripts/related/export.php --to=related_paragraphs_v1
+ *   php scripts/related/export.php --to=related_paragraphs_v1 --book=136,98,207   # 样例验货
+ */
+
+require __DIR__.'/lib/boot.php';
+
+use Illuminate\Support\Facades\DB;
+
+[$options] = rp_args($argv);
+$target = rp_assert_table($options['to'] ?? 'related_paragraphs_v1');
+$markersTable = $options['markers'] ?? 'rp_markers';
+$onlyBooks = isset($options['book']) ? array_map('intval', explode(',', $options['book'])) : null;
+
+$log = new RpLogger('export', rp_report_dir());
+$log->info("目标表:$target");
+if ($onlyBooks) {
+    $log->info('只导出:'.implode(',', $onlyBooks));
+}
+
+$config = rp_json_read(__DIR__.'/book_config.json');
+$bookConfig = $config['books'];
+$overrides = $config['overrides'] ?? [];
+
+// ── 建表 ─────────────────────────────────────────────────────────────────
+if (! $onlyBooks) {
+    DB::statement("drop table if exists $target");
+}
+DB::statement("
+    create table if not exists $target (
+        id bigserial primary key,
+        book integer not null,
+        para integer not null,
+        book_id integer not null,
+        cs_para integer not null,
+        book_name varchar(64) not null,
+        created_at timestamp(0), updated_at timestamp(0)
+    )
+");
+if ($onlyBooks) {
+    DB::table($target)->whereIn('book', $onlyBooks)->delete();
+}
+
+// ── 预载 ─────────────────────────────────────────────────────────────────
+$log->info('预载 book_titles…');
+$titles = [];
+foreach (DB::select('select book, paragraph, sn from book_titles order by book, paragraph') as $t) {
+    $titles[$t->book][] = ['paragraph' => (int) $t->paragraph, 'sn' => (int) $t->sn];
+}
+
+$books = array_map(
+    fn ($r) => (int) $r->book,
+    DB::select('select distinct book from pali_texts order by book')
+);
+if ($onlyBooks) {
+    $books = array_values(array_intersect($books, $onlyBooks));
+}
+$log->info('待导出文件数:'.count($books));
+
+$buffer = [];
+$written = 0;
+$now = date('Y-m-d H:i:s');
+
+$flush = function () use (&$buffer, &$written, $target) {
+    if ($buffer === []) {
+        return;
+    }
+    foreach (array_chunk($buffer, 5000) as $chunk) {
+        DB::table($target)->insert($chunk);
+    }
+    $written += count($buffer);
+    $buffer = [];
+};
+
+/** 在配置分段里查某段落该用什么书名;段落早于首段时沿用首段的名。 */
+$configName = function (int $book, int $para) use ($bookConfig, $overrides): ?string {
+    // overrides 是人工判定区,优先级高于自动推导出来的分段。
+    $segments = $overrides[(string) $book]['segments']
+        ?? $bookConfig[(string) $book]['segments']
+        ?? [];
+    if ($segments === []) {
+        return null;
+    }
+    $name = $segments[0]['book_name'];
+    foreach ($segments as $s) {
+        if ($para >= $s['para_from']) {
+            $name = $s['book_name'];
+        }
+    }
+
+    return $name;
+};
+
+foreach ($books as $book) {
+    $bookTitles = $titles[$book] ?? [];
+
+    // 段落全集取 pali_texts 而不是 wbw_templates:pali_texts 是严格超集
+    // (523284 段 vs 516113 段),wbw 少的那 7171 段散在 21 本里,
+    // 按 wbw 取全集会白丢这些段落的行。wbw 只负责提供标记。
+    $paragraphs = array_map(
+        fn ($r) => (int) $r->paragraph,
+        DB::select('select distinct paragraph from pali_texts where book = ? order by paragraph', [$book])
+    );
+    if ($paragraphs === []) {
+        continue;
+    }
+
+    $markerRows = DB::select(
+        "select paragraph, wid, word, kind, book_name, cs_from, cs_to, numbers, error
+         from $markersTable where book = ? and kind <> 'page' order by paragraph, wid",
+        [$book]
+    );
+    $byParagraph = [];
+    foreach ($markerRows as $m) {
+        $byParagraph[(int) $m->paragraph][] = $m;
+    }
+
+    // ── book_id 分段:book_titles 的每个条目开一段,之前的段落是书前页 ────────
+    $segments = [];
+    if ($bookTitles === []) {
+        $segments[] = ['sn' => 0, 'from' => 1, 'to' => PHP_INT_MAX];
+    } else {
+        if ($bookTitles[0]['paragraph'] > 1) {
+            $segments[] = ['sn' => 0, 'from' => 1, 'to' => $bookTitles[0]['paragraph'] - 1];
+        }
+        foreach ($bookTitles as $i => $t) {
+            $segments[] = [
+                'sn' => $t['sn'],
+                'from' => $t['paragraph'],
+                'to' => $bookTitles[$i + 1]['paragraph'] ?? PHP_INT_MAX,
+            ];
+            if (isset($bookTitles[$i + 1])) {
+                $segments[count($segments) - 1]['to'] = $bookTitles[$i + 1]['paragraph'] - 1;
+            }
+        }
+    }
+
+    $bookName = null;
+    $prologue = false;
+
+    // 义注文件的开头几段(书前页)在 {mula}_0 序言标记之前,但内容上属于序言。
+    // 文件里首个章标记是 _0 时,起手就用 {mula}_A,免得前几段挂到正文名下。
+    foreach ($markerRows as $m) {
+        if ($m->kind === 'chapter') {
+            if ($m->numbers === '0') {
+                $bookName = $m->book_name.'_A';
+                $prologue = true;
+                $log->count('event.prologue_seeded');
+            }
+            break;
+        }
+    }
+    // 文件自身没有任何书名标记时,书名只能全程由配置表分段决定。
+    // 配置表在所有书里都当书名基线:它已经把 book_tag 书界、人工 override 和
+    // 序言段(dn1_A 型)合并成一份完整分段。标记仍然可以就地覆盖——paraN_xxx 后缀
+    // 是最贴身的证据,book_tag 是书界事件,两者都在下面的标记循环里生效。
+    $nameFromConfig = true;
+
+    foreach ($segments as $seg) {
+        $segParagraphs = array_values(array_filter(
+            $paragraphs,
+            fn ($p) => $p >= $seg['from'] && $p <= $seg['to']
+        ));
+        if ($segParagraphs === []) {
+            continue;
+        }
+
+        // ⭐ 防污染核心规则:整段没有任何 cs 标记,就不许给它任何非零 cs。
+        // 旧生成器在这里把换书前的 cs 一路铺下去,book 207 的 1499 行恒 cs=75 就是这么来的。
+        // 但也不能整段不输出:这些段落的行多半是 cs=0 的占位行(E4 的无注释体系书、
+        // 义注序言、书前页),下游 ParaInfoController 按段落取行,删掉就是白丢覆盖。
+        // 输出 cs=0 即可——index() 取锚点时要求 cs_para > 0,cs=0 行不会产生任何配对,
+        // 既保住覆盖又不编造关系。仍然记 issue,标出待 LLM 补齐的位置。
+        $segMarkerCount = 0;
+        foreach ($segParagraphs as $p) {
+            foreach ($byParagraph[$p] ?? [] as $m) {
+                if (in_array($m->kind, ['single', 'interval'], true)) {
+                    $segMarkerCount++;
+                }
+            }
+        }
+        if ($segMarkerCount === 0 && $seg['sn'] !== 0) {
+            $log->count('segment_without_markers');
+            $log->unresolved('segment_without_markers', [
+                'book' => $book, 'book_id' => $seg['sn'],
+                'para_from' => $segParagraphs[0], 'para_to' => end($segParagraphs),
+                'paragraphs' => count($segParagraphs),
+                'detail' => '整段无 cs 标记,全部输出 cs=0 占位,待补(E8/E9)',
+            ]);
+        }
+
+        // 段起点:cs 归零等待首个标记。
+        // 书名分两种来源:文件自带 book_tag/后缀标记的,交给标记驱动;没有任何现场
+        // 证据的(123 本,如 book 98 一个文件里装了 abhi3..abhi7),只能逐段落查配置表
+        // ——这类文件的书界在 wbw 里没有任何痕迹。
+        $csList = [0];
+        $bookName ??= $configName($book, $segParagraphs[0]);
+
+        foreach ($segParagraphs as $para) {
+            $skipParagraph = false;
+
+            $configNameHere = $configName($book, $para);
+            if ($nameFromConfig) {
+                $bookName = $configNameHere;
+            }
+
+            foreach ($byParagraph[$para] ?? [] as $m) {
+                switch ($m->kind) {
+                    case 'book_tag':
+                        // 文件内的书界切换(an2@1 an3@323…),新书从 cs=0 起算
+                        if ($bookName !== $m->book_name) {
+                            $log->count('event.book_switch');
+                        }
+                        $bookName = $m->book_name;
+                        $prologue = false;
+                        $csList = [0];
+                        break;
+
+                    case 'chapter':
+                        $numbers = $m->numbers === null ? [] : array_map('intval', explode('.', $m->numbers));
+                        if ($numbers === [0]) {
+                            // {mula}_0 = 义注开头的序言(Ganthārambhakathā),不对应原文段落
+                            $prologue = true;
+                            $bookName = $m->book_name.'_A';
+                            $csList = [0];
+                            $log->count('event.prologue_begin');
+                        } else {
+                            if ($prologue) {
+                                $prologue = false;
+                                $bookName = $m->book_name;
+                                $csList = [0];
+                                $log->count('event.prologue_end');
+                            }
+                            $log->count('event.chapter');
+                        }
+                        break;
+
+                    case 'single':
+                    case 'interval':
+                        // 序言段里的 paraN_mula 后缀不夺名:序言归 {mula}_A,
+                        // 后缀只说明它在注释哪一段,不表示它已经进入正文
+                        // (book 181 的 an1_A@1-138 内就有 para1_an1 起的一串)。
+                        if ($m->book_name !== null && $configNameHere !== $m->book_name.'_A') {
+                            $bookName = $m->book_name;
+                        }
+                        // cs 标记不结束序言:序言里带 paraN_mula 是正常体例
+                        // (book 130 的 mn1_0 序言内就有 para1_mn1),序言只由
+                        // {mula}_{N>0} 章标记结束。
+                        $csList = range((int) $m->cs_from, (int) $m->cs_to);
+                        $log->count($m->kind === 'interval' ? 'marker.interval' : 'marker.single');
+                        break;
+
+                    case 'truncated':
+                        // para179- 型:区间右端缺失,本段留空,上下文不变
+                        $skipParagraph = true;
+                        $log->unresolved('truncated_marker', [
+                            'book' => $book, 'paragraph' => $para, 'word' => $m->word,
+                            'detail' => $m->error ?? '区间右端缺失',
+                        ]);
+                        break;
+
+                    default:
+                        $log->count('marker.ignored.'.$m->kind);
+                }
+            }
+
+            if ($skipParagraph) {
+                continue;
+            }
+
+            if ($bookName === null) {
+                // 没有任何书名证据:E4 的无注释体系书走到这里是正常的,输出空名占位
+                $bookName = '';
+            }
+
+            foreach ($csList as $cs) {
+                $buffer[] = [
+                    'book' => $book, 'para' => $para, 'book_id' => $seg['sn'],
+                    'cs_para' => $cs, 'book_name' => $bookName,
+                    'created_at' => $now, 'updated_at' => $now,
+                ];
+            }
+            $log->count('rows.emitted', count($csList));
+        }
+    }
+
+    $flush();
+    $log->info(sprintf('book %-3d 完成,累计 %d 行', $book, $written));
+}
+
+$flush();
+$log->info("写入完成:$written 行");
+
+if (! $onlyBooks) {
+    $log->info('建索引中…');
+    DB::statement("create index on $target (book)");
+    DB::statement("create index on $target (para)");
+    DB::statement("create index on $target (book_name)");
+    DB::statement("create index on $target (book_name, cs_para)");
+    DB::statement("create index on $target (book, para, cs_para)");
+}
+
+$log->finish();

+ 390 - 0
api-v13/scripts/related/known_issues.json

@@ -0,0 +1,390 @@
+{
+  "version": 1,
+  "purpose": "related_paragraphs 重建工程的已知错误登记表:每条一个可机检的探测器,带旧表实测基线与各阶段目标。check.php 按此表给任意一张 related 表打分,用来回答『这一轮错误是不是变少了』。",
+  "doc_ref": "api-v13/documents/related-paragraphs-redesign.md",
+  "baseline_table": "related_paragraphs_bak_20260909",
+  "baseline_database": "visuddhinanda_20260311",
+  "baseline_measured_at": "2026-09-09",
+  "params": {
+    "cs_garbage_threshold": 5000,
+    "cs_observed_max_legit": 3183,
+    "flat_min_rows": 200,
+    "flat_max_distinct_ratio": 50
+  },
+  "notes": [
+    "cs6_para.csv 并未丢失(github.com/iapt-platform/clove/pali_title/cs6_para.csv,523283 行),与设计文档 §6.1 的表述不符。它是现表的来源,含全部历史错误,因此不作生成源,只作 baseline 与 book 129 的回填源。",
+    "逐本比对 csv 非零行 vs wbw 标记数:只有 book 129 是『csv 有 6580 非零行、wbw 零标记』。E9 其余 15 本 csv 里也是全零,源头就没有。",
+    "基线数字取自旧表实测,与文档 §4.3/§8 的差异已在各条 baseline_note 注明。",
+    "E8 判据在实测中收窄,详见该条 baseline_note;旧的「cs 值种类少」判法会把章回绕误报成污染。",
+    "MarkerParser 全库实跑:330267 个锚点 100% 归类,零 unknown。新发现两类文档未记录的形态——倒序区间 3 行(para504-407 / para706-608,高位笔误,按低位补齐还原为 504-507 / 706-708,已由前后邻标记验证)与多级章标记 247 行(an5_1_1 型,13 本,义注书 100-102 与 mūla 书 82-92 共用同一套编号,是跨文件章界对齐的抓手)。",
+    "para504-407 丢段的完整链路已查清:cs6_para.csv 原样写了 begin=504,end=407,旧 Laravel 命令的 for ($i=$begin; $i<=$end; $i++) 一圈没进——是导入环节静默吞掉的,不是生成环节。"
+  ],
+  "detectors": [
+    {
+      "id": "E1_cs_garbage",
+      "title": "cs 乱码值",
+      "doc_ref": "§4.3 E1",
+      "severity": "blocker",
+      "baseline": {
+        "rows": 1242,
+        "books": 24
+      },
+      "target_v1": {
+        "rows": 0
+      },
+      "target_final": {
+        "rows": 0
+      },
+      "root_cause": "旧生成器解析无后缀区间标记 para7-10 产生废值(如 44022)",
+      "fix_stage": "batch1 导出器:超值域拒绝输出 + issue"
+    },
+    {
+      "id": "E2_chapter_wrap",
+      "title": "章回绕(cs 从 1 重新计数)",
+      "doc_ref": "§4.3 E2",
+      "severity": "blocker",
+      "baseline": {
+        "wrap_points": 395,
+        "books": 64
+      },
+      "baseline_note": "原写 430、后按逐行判据实测 429,两者都偏高:区间标记展开后一个段落有多行(para1-2 展开成 cs=1、cs=2 两行),连续这样的段落逐行看就是 …2,1,2,1…,每段都像一次重置。book 137 的 sn5「5 次回绕」、book 108 的 kn12「95 次回绕」全是这么来的假象。改按段落级 cs_min 判后,旧表实测 395 处 / 64 本。",
+      "target_v1": {
+        "wrap_points": "unchanged"
+      },
+      "target_final": {
+        "wrap_points": "<50"
+      },
+      "root_cause": "VRI 标记是章内局部编号,章界处重置,旧生成器照搬",
+      "fix_stage": "batch3 章后缀;v1 不处理,先让确定性规则跑干净"
+    },
+    {
+      "id": "E3_kn10_missing",
+      "title": "kn10 家族命名错乱,apadāna 注释链断裂",
+      "doc_ref": "§4.3 E3",
+      "severity": "blocker",
+      "baseline": {
+        "kn10_rows": 0,
+        "kn10_a_rows": 16787,
+        "kn10_b_rows": 12407
+      },
+      "baseline_note": "kn10_A/kn10_B 行数取自 csv album 统计,旧表同源",
+      "target_v1": {
+        "kn10_rows": ">6000"
+      },
+      "target_final": {
+        "kn10_rows": ">6000"
+      },
+      "root_cause": "therā/therī(book 143/144)被赋 kn10_A/kn10_B 且归属互相矛盾;wbw 中 paraN_kn10 标记 6533 行在 related 中映射丢失",
+      "fix_stage": "batch1 书级配置表"
+    },
+    {
+      "id": "E4_empty_book_name",
+      "title": "空 book_name(无注释体系书)",
+      "doc_ref": "§4.3 E4",
+      "severity": "expected",
+      "baseline": {
+        "rows": 112965
+      },
+      "target_v1": {
+        "rows": "~112965"
+      },
+      "target_final": {
+        "rows": "~112965"
+      },
+      "root_cause": "nīti/语法/词典类书无 cs 标记体系,不参与注释关系",
+      "fix_stage": "不修,维持现状(§6.7)"
+    },
+    {
+      "id": "E5_book_id_zero",
+      "title": "book_id 未赋值",
+      "doc_ref": "§4.3 E5",
+      "severity": "minor",
+      "baseline": {
+        "rows": 354,
+        "books": 201
+      },
+      "target_v1": {
+        "rows": "<=354"
+      },
+      "target_final": {
+        "rows": "<=354"
+      },
+      "root_cause": "各文件 para 1-2 书前页,语义正确;另 sn 280/281 整段归错前册",
+      "fix_stage": "batch1 book_id 层按 book_titles 重跑"
+    },
+    {
+      "id": "E6_coverage_gap",
+      "title": "覆盖缺口(wbw 有段落但 related 无行)",
+      "doc_ref": "§4.3 E6",
+      "severity": "major",
+      "baseline": {
+        "segments": 3466
+      },
+      "target_v1": {
+        "segments": "<500"
+      },
+      "target_final": {
+        "segments": "<100"
+      },
+      "root_cause": "主体为缩写区间丢失(E7),其余为 wbw_templates 整段缺失",
+      "fix_stage": "batch1 + batch4"
+    },
+    {
+      "id": "E7_abbrev_interval_lost",
+      "title": "缩写区间丢失(para292-3 型)",
+      "doc_ref": "§4.3 E7",
+      "severity": "blocker",
+      "baseline": {
+        "marker_segments": 1517,
+        "books": 44,
+        "segments_without_rows": 1292,
+        "segments_with_garbage": 225
+      },
+      "target_v1": {
+        "segments_without_rows": 0,
+        "segments_with_garbage": 0
+      },
+      "target_final": {
+        "segments_without_rows": 0,
+        "segments_with_garbage": 0
+      },
+      "root_cause": "VRI 缩写语法 para292-3(=292-293)旧生成器不识别:多数跳过,少数误解析为废值",
+      "fix_stage": "batch1 导出器缩写补齐规则"
+    },
+    {
+      "id": "E8_flat_pollution",
+      "title": "书边界污染平铺",
+      "doc_ref": "§4.3 E8",
+      "severity": "blocker",
+      "baseline": {
+        "groups": 1,
+        "rows": 1499
+      },
+      "baseline_note": "实测复核后收窄了判据:污染的准确签名是「整段零 cs 标记却被写进非零 cs」。按此判全库只有 book 207 / book_id 259 kaṅkhāvitaraṇī 一段(1499 行恒 cs=75)。文档 §4.3 原列的另外几组里,book 212/273(669 行)、book 180/213、book 209/261 都是 cs 恒 0,0 是「未赋值」不是编造值,不算污染;book 152/46/116/154/204 则是章回绕(E2)被误判——book 152 有 516 个标记却只 24 个 cs 值,正是 22 次回绕造成的。",
+      "target_v1": {
+        "groups": 0
+      },
+      "target_final": {
+        "groups": 0
+      },
+      "root_cause": "换书(book_id 切换)后无标记,旧生成器无限沿用上一个 cs 值",
+      "fix_stage": "batch1 留空 + batch4 LLM"
+    },
+    {
+      "id": "E9_no_marker_books",
+      "title": "全书无标记(related 有行但 wbw 零标记)",
+      "doc_ref": "§4.3 E9",
+      "severity": "major",
+      "baseline": {
+        "books": 16,
+        "book_list": [
+          8,
+          15,
+          16,
+          25,
+          37,
+          39,
+          40,
+          41,
+          42,
+          44,
+          45,
+          47,
+          48,
+          56,
+          61,
+          129
+        ]
+      },
+      "target_v1": {
+        "books": 16,
+        "note": "v1 只按 wbw 重放,这些书自然无行"
+      },
+      "target_final": {
+        "books": 15,
+        "note": "book 129 用 csv 回填(csv 有 6580 非零行),其余 15 本 csv 也是全零,源头没有"
+      },
+      "root_cause": "VRI 源文件无锚点",
+      "fix_stage": "batch4:129 用 csv,其余维持现状",
+      "baseline_note": ""
+    },
+    {
+      "id": "E10_selfmade_names",
+      "title": "自造名体系(NK / subo / dict)",
+      "doc_ref": "§4.3 E10",
+      "severity": "expected",
+      "baseline": {
+        "nk_rows": 985,
+        "subo_rows": 4256,
+        "dict_rows": 6646
+      },
+      "baseline_note": "行数为 csv album 口径的近似值,check.php 会实测回填",
+      "target_v1": {
+        "note": "保留不动,架构正确"
+      },
+      "target_final": {
+        "note": "保留不动"
+      },
+      "root_cause": "非 SC 体系书对的自造 album 名,与 dn1/dn1_A 同构",
+      "fix_stage": "不改名,仅修其中的乱码/缩写(§6.7)"
+    },
+    {
+      "id": "D_continuity",
+      "title": "同 (book, book_name, cs_para) 的 para 不连续",
+      "doc_ref": "§4.2 正确性判据",
+      "severity": "watch",
+      "baseline": {
+        "broken_groups": 9026,
+        "books": 69,
+        "extra_segments": 30204
+      },
+      "target_v1": {
+        "broken_groups": "decrease"
+      },
+      "target_final": {
+        "broken_groups": "decrease"
+      },
+      "root_cause": "部分为合法的回指补充(cs 小幅回退),部分为章回绕与污染导致的真错",
+      "fix_stage": "观察项,不做硬断言;batch3 后应显著下降"
+    },
+    {
+      "id": "D_mirror_alignment",
+      "title": "镜像型书对两侧 max(cs_para) 一致",
+      "doc_ref": "§4.2 / §8",
+      "severity": "blocker",
+      "baseline": {
+        "aligned": {
+          "dn1": 559,
+          "kn6": 1289,
+          "kn9": 524,
+          "abhi5": 918
+        },
+        "misaligned": [
+          "abhi3",
+          "abhi4",
+          "abhi6",
+          "abhi7"
+        ]
+      },
+      "target_v1": {
+        "note": "aligned 四组必须保持相等;misaligned 四组 batch3 处理"
+      },
+      "target_final": {
+        "note": "全部对齐"
+      },
+      "root_cause": "义注/复注段的标记即被注书段落号;abhidhamma 系自有编号导致跨侧错位",
+      "fix_stage": "batch1 出口断言 + batch3 补丁后缀"
+    },
+    {
+      "id": "D_interval_expansion",
+      "title": "区间标记展开行数 == M − N + 1",
+      "doc_ref": "§6.3 验收断言",
+      "severity": "blocker",
+      "baseline": {
+        "note": "旧表未做此断言,check.php 首跑回填实测值"
+      },
+      "target_v1": {
+        "mismatched_markers": 0
+      },
+      "target_final": {
+        "mismatched_markers": 0
+      },
+      "root_cause": "旧生成器对 151-152 型一对多展开不完整",
+      "fix_stage": "batch1"
+    },
+    {
+      "id": "D_book_regression",
+      "title": "基线合格书回归",
+      "doc_ref": "§5 汇总",
+      "severity": "blocker",
+      "baseline": {
+        "note": "首跑生成 baseline.json,冻结旧表中零错误信号的书"
+      },
+      "target_v1": {
+        "regressed_books": 0
+      },
+      "target_final": {
+        "regressed_books": 0
+      },
+      "root_cause": "重建可能破坏本来正确的书",
+      "fix_stage": "每轮必检"
+    },
+    {
+      "id": "D_pairing",
+      "title": "配对质量(可信配对段数 / 膨胀键)",
+      "doc_ref": "§3 查询逻辑 / §6.1 回绕处理",
+      "severity": "blocker",
+      "baseline": {
+        "rows_paired_trusted": 177016,
+        "keys_bloated": 6671,
+        "keys_bloated_bad": 1413
+      },
+      "target_v1": {
+        "rows_paired_trusted": ">=177016"
+      },
+      "target_final": {
+        "rows_paired_trusted": "尽量大",
+        "keys_bloated_bad": "尽量小"
+      },
+      "root_cause": "章回绕让同一个 (book_name, cs_para) 在几十个章里重复,index() 一次查询捞回上千段",
+      "note": "可信配对 = key 跨文件命中、且在任何单本书里都不超过 10 段。直接数 rows_paired 会把回绕噪声算成资产——kn10 加后缀后 rows_paired 从 23757 掉到 6279 看着像灾难,其实掉的几乎全是 book 144章 43-56 的虚假配对(那几章本来就没有义注,是靠撞在一起的 kn10:1 假装配上的)。",
+      "fix_stage": "batch3 章后缀 + batch4 补丁"
+    }
+  ],
+  "rounds": [
+    {
+      "round": 1,
+      "date": "2026-09-09",
+      "table": "related_paragraphs_v1",
+      "rows": 638076,
+      "scripts": "build_markers.php → build_book_config.php → export.php → check.php → diff.php",
+      "result": {
+        "E1_cs_garbage": "1242 → 0",
+        "E3_kn10_missing": "0 → 25506(kn10_A/kn10_B 清零)",
+        "E6_coverage_gap": "3464 → 1(仅 book 11 para 1577 的截断标记)",
+        "E7_abbrev_interval_lost": "1292 段无行 + 225 段废值 → 0 + 0",
+        "E8_flat_pollution": "1499 行 → 0",
+        "D_interval_expansion": "1611 个区间标记展开不符 → 0",
+        "D_mirror_alignment": "四组镜像判据保持(dn1 559 / kn6 1289 / kn9 524 / abhi5 918)",
+        "clean_books": "128 → 148",
+        "E2_chapter_wrap": "429 → 677(未处理,留给章后缀批次;旧表的乱码与污染此前掩盖了部分真回绕)"
+      },
+      "diff_vs_old": {
+        "same": "480290 段 / 91.8%",
+        "only_in_old": "1 段(截断标记)",
+        "only_in_new": "3466 段(区间展开补回,与旧表的覆盖缺口数一致)",
+        "cs_changed": "10371 段 —— 8600 段新归零(防污染/防越界沿用)、528 段新赋值、1290 段两侧都非零而边界不同",
+        "name_changed": "29156 段 —— 29146 段是 kn10 家族修复(books 143/144/107),其余 10 段是各文件 book_tag 所在段本身"
+      },
+      "open": [
+        "book 129(6605 段)现为 cs=0 占位,待用 cs6_para.csv 回填(csv 有 6580 非零行)",
+        "20 段 segment_without_markers 已输出 cs=0 占位,待 LLM 归属",
+        "book 11 para 1577 的 para179- 截断标记待人工定",
+        "E2 章回绕 677 处未处理",
+        "1290 段两侧都非零而边界不同,需抽样复核(book 110 已查:旧表把 cs=5 边界提前 7 段,wbw 无依据,新值站得住)"
+      ]
+    },
+    {
+      "round": 2,
+      "date": "2026-09-09",
+      "table": "related_paragraphs_v2",
+      "scripts": "align_chapters.php → apply_chapters.php",
+      "approach": "跨侧章对齐:以 mūla 侧的 cs 重置段为章号定义,注释侧用带空位的序列比对(Needleman–Wunsch,打分基于两侧每章 max(cs) 的接近程度)对齐过去,两侧用同一套章号加后缀。每个 book_name 改名前后实测一次可信配对段数,掉了就整名回滚——指标把关,不是规则把关。",
+      "result": {
+        "E2_chapter_wrap": "399 → 116(旧表 395)",
+        "D_pairing.rows_paired_trusted": "186744 → 232557(旧表 177016,+31%)",
+        "D_pairing.keys_cross_book": "29005 → 34878",
+        "D_pairing.keys_bloated_bad": "1259 → 958(旧表 1413)",
+        "D_mirror_alignment": "0 偏离,保持",
+        "clean_books": "156 → 189(旧表 130)",
+        "applied": "17 个 book_name,改名 165779 行,无一回归"
+      },
+      "open": [
+        "5 个名字加后缀后可信配对归零或下跌,已自动回滚:vin8、dict、abhi8、vin7(并列的两本书被当成顺序拼接)、abhi7(3429→2940)",
+        "20 个名字跨侧对齐置信不足未处理:abhi2 abhi9 an11 an4 an5 an7 an9 kn2 kn3 kn6 kn7 mn1 mn1_A sn1 sn5 vin1 vin2 vin3 vin4 vin5——它们的共同特征是 mūla 侧 cs 单调无回绕、回绕只在注释侧,两侧用的不是同一套编号,序列比对无从下手",
+        "剩余 116 处回绕分布在 27 本",
+        "章标题不能当通用对齐信号:book 143(1. Buddhavaggo…)与 book 107(Dīpaṅkaro buddho…)标题完全对不上,文档 §6.4 的三层同名标题只适用于 vinaya 那组"
+      ]
+    }
+  ]
+}

+ 116 - 0
api-v13/scripts/related/lib/Logger.php

@@ -0,0 +1,116 @@
+<?php
+
+/**
+ * 重建工程专用日志器。
+ *
+ * 迭代流程是「脚本跑 → 日志和报告交给 LLM 评估 → 改规则 → 再跑」,所以日志的读者
+ * 是模型不是人:宁可啰嗦,但必须结构化。unresolved() 记录的是脚本自己判定
+ * 「我处理不了」的点,它们是下一轮规则迭代的输入,单独成文件。
+ */
+class RpLogger
+{
+    private $handle;
+
+    private array $counters = [];
+
+    /** @var array<int, array<string, mixed>> */
+    private array $unresolved = [];
+
+    private float $startedAt;
+
+    public function __construct(
+        private string $name,
+        private string $reportDir,
+        private bool $echoToStdout = true,
+    ) {
+        $this->startedAt = microtime(true);
+        $this->handle = fopen($this->reportDir."/{$name}.log", 'w');
+        $this->line('INFO', "==== $name 开始 ".date('Y-m-d H:i:s').' ====');
+    }
+
+    public function info(string $message): void
+    {
+        $this->line('INFO', $message);
+    }
+
+    public function warn(string $message): void
+    {
+        $this->line('WARN', $message);
+    }
+
+    public function error(string $message): void
+    {
+        $this->line('ERROR', $message);
+    }
+
+    /** 计数器:高频事件不逐条打印,只累计,收尾时汇总。 */
+    public function count(string $key, int $n = 1): void
+    {
+        $this->counters[$key] = ($this->counters[$key] ?? 0) + $n;
+    }
+
+    public function counters(): array
+    {
+        return $this->counters;
+    }
+
+    /**
+     * 脚本无法判定的点。type 是规则名,供下一轮按类型聚合决定要不要加规则。
+     */
+    public function unresolved(string $type, array $context): void
+    {
+        $this->unresolved[] = ['type' => $type] + $context;
+        $this->count("unresolved.$type");
+    }
+
+    /** 收尾:写计数汇总与 unresolved 清单,返回汇总数组。 */
+    public function finish(): array
+    {
+        $elapsed = round(microtime(true) - $this->startedAt, 1);
+        ksort($this->counters);
+
+        $this->line('INFO', '---- 计数汇总 ----');
+        foreach ($this->counters as $key => $value) {
+            $this->line('INFO', sprintf('  %-46s %10d', $key, $value));
+        }
+
+        $byType = [];
+        foreach ($this->unresolved as $item) {
+            $byType[$item['type']] = ($byType[$item['type']] ?? 0) + 1;
+        }
+        if ($byType !== []) {
+            $this->line('WARN', '---- 未决问题(按类型) ----');
+            foreach ($byType as $type => $n) {
+                $this->line('WARN', sprintf('  %-46s %10d', $type, $n));
+            }
+        }
+
+        $path = $this->reportDir."/{$this->name}.unresolved.json";
+        rp_json_write($path, [
+            'generated_at' => date('c'),
+            'total' => count($this->unresolved),
+            'by_type' => $byType,
+            'items' => $this->unresolved,
+        ]);
+
+        $this->line('INFO', "未决清单:$path");
+        $this->line('INFO', "==== {$this->name} 结束,耗时 {$elapsed}s ====");
+        fclose($this->handle);
+
+        return [
+            'counters' => $this->counters,
+            'unresolved_total' => count($this->unresolved),
+            'unresolved_by_type' => $byType,
+            'elapsed_seconds' => $elapsed,
+        ];
+    }
+
+    private function line(string $level, string $message): void
+    {
+        $row = sprintf('[%s] %-5s %s', date('H:i:s'), $level, $message);
+        fwrite($this->handle, $row."\n");
+        if ($this->echoToStdout) {
+            fwrite(STDOUT, $row."\n");
+        }
+    }
+}

+ 202 - 0
api-v13/scripts/related/lib/MarkerParser.php

@@ -0,0 +1,202 @@
+<?php
+
+/**
+ * wbw_templates 里 .ctl. 锚点标记的解析器。
+ *
+ * 这些标记来自 VRI html 的 <a name="..."> 锚点,是整个 related_paragraphs 的唯一
+ * 事实来源。旧生成器(cs6_para.csv 那条链路)在这里翻过车——缩写区间被跳过或
+ * 误解析成六位数废值——所以解析规则集中在这一个类里,导出器和检测器共用同一份,
+ * 不允许各写一遍。
+ *
+ * 形态实测(type='.ctl.' and style='#a#',330267 行):
+ *   pagemark 167647 / paraN 116921 / paraN_book 39998 / paraN-M 3611
+ *   / 书名·章标记 1096 / paraN-M_book 989 / 链式 4 / 截断 1
+ */
+class MarkerParser
+{
+    /** 解析结果超过此值一律视为废值,拒绝输出。实测合法上界 3183。 */
+    public const CS_MAX = 5000;
+
+    public const KIND_SINGLE = 'single';          // para4
+
+    public const KIND_INTERVAL = 'interval';      // para151-152 / para292-3 / para292-3-6
+
+    public const KIND_BOOK_TAG = 'book_tag';      // dn1
+
+    public const KIND_CHAPTER = 'chapter';        // dn1_9 / an5_1_1 —— 章标记或义注标记,同形歧义
+
+    public const KIND_PAGE = 'page';              // P1.0023
+
+    public const KIND_TRUNCATED = 'truncated';    // para179-
+
+    public const KIND_UNKNOWN = 'unknown';
+
+    /**
+     * @return array{
+     *   kind: string,
+     *   cs: list<int>,          解析出的 cs 值(已展开区间)
+     *   book_name: ?string,     标记自带的书名后缀
+     *   number: ?int,           chapter 形态的首级编号
+     *   numbers: ?list<int>,    chapter 形态的完整编号路径(an5_1_1 → [1,1])
+     *   repaired: ?string,      规则修补过的标记,须记 issue 复核
+     *   raw: string,
+     *   error: ?string,         非空表示拒绝输出,调用方须记 issue
+     * }
+     */
+    public static function parse(string $word): array
+    {
+        $base = [
+            'kind' => self::KIND_UNKNOWN,
+            'cs' => [],
+            'book_name' => null,
+            'number' => null,
+            'numbers' => null,
+            'repaired' => null,
+            'raw' => $word,
+            'error' => null,
+        ];
+
+        // 页码/卷标 P/M/V/T/O × 0-5:本轮一律忽略(§6.7)
+        if (preg_match('/^[A-Z][0-9]/', $word)) {
+            return ['kind' => self::KIND_PAGE] + $base;
+        }
+
+        if (! str_starts_with($word, 'para')) {
+            // 纯书名 dn1(书上下文事件)或 dn1_9(章标记 / 义注标记)
+            // 章标记,可带多级编号路径:dn1_9 是一级,an5_1_1 是两级(实测 13 本 247 行,
+            // 义注书 100-102 与 mūla 书 82-92 共用同一套编号,是跨文件章界对齐的抓手)。
+            if (preg_match('/^([a-z][a-z0-9]*)((?:_[0-9]+)+)$/', $word, $m)) {
+                $numbers = array_values(array_map('intval', array_filter(explode('_', $m[2]), 'strlen')));
+
+                return [
+                    'kind' => self::KIND_CHAPTER,
+                    'book_name' => $m[1],
+                    'number' => $numbers[0],
+                    'numbers' => $numbers,
+                ] + $base;
+            }
+            if (preg_match('/^[a-z][a-z0-9]*$/', $word)) {
+                return ['kind' => self::KIND_BOOK_TAG, 'book_name' => $word] + $base;
+            }
+
+            return $base;
+        }
+
+        $body = substr($word, 4);
+
+        // 尾部 _book 后缀。区间与单值都可能带,如 para1008-9_sn5(实测 989 行)。
+        $bookName = null;
+        if (preg_match('/^(.*?)_([a-z][a-z0-9_]*)$/', $body, $m)) {
+            $body = $m[1];
+            $bookName = $m[2];
+        }
+
+        if ($body === '' || ! preg_match('/^[0-9]/', $body)) {
+            return ['error' => '标记主体不是数字'] + $base;
+        }
+
+        // para179- :区间右端缺失,全库仅 book 11 para 1577 一处,留给人工。
+        if (str_ends_with($body, '-')) {
+            return ['kind' => self::KIND_TRUNCATED, 'book_name' => $bookName, 'error' => '区间右端缺失'] + $base;
+        }
+
+        $parts = explode('-', $body);
+        foreach ($parts as $part) {
+            if (! preg_match('/^[0-9]+$/', $part)) {
+                return ['error' => "区间片段非数字:$part"] + $base;
+            }
+        }
+
+        if (count($parts) === 1) {
+            $cs = (int) $parts[0];
+            if ($cs > self::CS_MAX) {
+                return ['book_name' => $bookName, 'error' => "cs 超值域:$cs"] + $base;
+            }
+
+            return ['kind' => self::KIND_SINGLE, 'cs' => [$cs], 'book_name' => $bookName] + $base;
+        }
+
+        // 链式 para292-3-6:逐段以前一个完整值补齐,取首尾展开(实测仅 4 行)。
+        $start = (int) $parts[0];
+        $current = $start;
+        for ($i = 1; $i < count($parts); $i++) {
+            $current = self::completePrefix($current, $parts[$i]);
+            if ($current === null) {
+                return ['book_name' => $bookName, 'error' => "区间右端无法补齐:$body"] + $base;
+            }
+        }
+        $end = $current;
+
+        $repaired = null;
+        if ($end < $start) {
+            // 倒序区间是 VRI 源里的高位笔误,低位是对的:实测 para504-407 前邻 502-503
+            // 后邻 508(真值 504-507)、para706-608 前邻 703-705 后邻 709(真值 706-708)。
+            // 丢掉右端高位、只用低位重新补齐即可还原。全库仅 3 行,一律记 issue 人工复核。
+            $tail = end($parts);
+            $end = self::repairReversedEnd($start, $tail);
+            if ($end === null) {
+                return ['book_name' => $bookName, 'error' => "区间倒序且无法修补:$body"] + $base;
+            }
+            $repaired = "倒序区间按低位补齐:$body → $start-$end";
+        }
+        if ($end > self::CS_MAX || $start > self::CS_MAX) {
+            return ['book_name' => $bookName, 'error' => "cs 超值域:$start-$end"] + $base;
+        }
+        if ($end - $start > 2000) {
+            return ['book_name' => $bookName, 'error' => "区间过宽:$start-$end"] + $base;
+        }
+
+        return [
+            'kind' => self::KIND_INTERVAL,
+            'cs' => range($start, $end),
+            'book_name' => $bookName,
+            'repaired' => $repaired,
+        ] + $base;
+    }
+
+    /**
+     * VRI 缩写区间语法:右端只写变化的低位,用左端补齐前缀。
+     *   292-3   → 293      (补 "29")
+     *   1008-9  → 1009     (补 "100")
+     *   15-8    → 18       (补 "1")
+     *   298-3   → 303      (补出来 293 < 298,进位加 10^1)
+     * 右端位数不少于左端时不是缩写,原样返回。
+     */
+    public static function completePrefix(int $left, string $rightDigits): ?int
+    {
+        $leftDigits = (string) $left;
+        $rightLen = strlen($rightDigits);
+
+        if ($rightLen >= strlen($leftDigits)) {
+            return (int) $rightDigits;
+        }
+
+        $prefix = substr($leftDigits, 0, strlen($leftDigits) - $rightLen);
+        $completed = (int) ($prefix.$rightDigits);
+
+        if ($completed <= $left) {
+            // 低位回绕,向上进一位:298-3 的 3 指的是 303 不是 293。
+            $completed += 10 ** $rightLen;
+        }
+
+        return $completed > $left ? $completed : null;
+    }
+
+    /**
+     * 倒序区间的还原:逐步丢弃右端高位,只保留低位再按前缀补齐,取第一个大于左端
+     * 且区间宽度合理的结果。
+     *   504-407 → 低两位 "07" 补成 507
+     *   706-608 → 低两位 "08" 补成 708
+     */
+    public static function repairReversedEnd(int $start, string $rightDigits): ?int
+    {
+        for ($keep = strlen($rightDigits) - 1; $keep >= 1; $keep--) {
+            $candidate = self::completePrefix($start, substr($rightDigits, -$keep));
+            if ($candidate !== null && $candidate > $start && $candidate - $start <= 20) {
+                return $candidate;
+            }
+        }
+
+        return null;
+    }
+}

+ 85 - 0
api-v13/scripts/related/lib/boot.php

@@ -0,0 +1,85 @@
+<?php
+
+/**
+ * related_paragraphs 重建工程的公共引导。
+ *
+ * 这些脚本刻意留在 scripts/ 而不是 app/Console/Commands/:重建期间它们会被反复
+ * 改写、丢弃、重跑,不属于应用的常驻命令面。导出器稳定后再收编成 artisan command。
+ */
+
+use Illuminate\Contracts\Console\Kernel;
+
+require __DIR__.'/../../../vendor/autoload.php';
+
+$app = require __DIR__.'/../../../bootstrap/app.php';
+$app->make(Kernel::class)->bootstrap();
+
+require __DIR__.'/Logger.php';
+
+/** 本工程所有产物的落地目录。 */
+function rp_report_dir(): string
+{
+    $dir = __DIR__.'/../report';
+    if (! is_dir($dir)) {
+        mkdir($dir, 0o755, true);
+    }
+
+    return $dir;
+}
+
+function rp_data_dir(): string
+{
+    return __DIR__.'/../data';
+}
+
+/** 命令行参数:--key=value / --flag,返回 [$options, $positional]。 */
+function rp_args(array $argv): array
+{
+    $options = [];
+    $positional = [];
+    foreach (array_slice($argv, 1) as $arg) {
+        if (str_starts_with($arg, '--')) {
+            $body = substr($arg, 2);
+            if (str_contains($body, '=')) {
+                [$k, $v] = explode('=', $body, 2);
+                $options[$k] = $v;
+            } else {
+                $options[$body] = true;
+            }
+        } else {
+            $positional[] = $arg;
+        }
+    }
+
+    return [$options, $positional];
+}
+
+/**
+ * 表名白名单校验。这些脚本会把表名拼进 SQL(标识符不能用绑定参数),
+ * 所以只允许我们自己的表族,避免手滑打错表名时打到生产数据。
+ */
+function rp_assert_table(string $table): string
+{
+    if (! preg_match('/^related_paragraphs(_[a-z0-9_]+)?$/', $table)) {
+        throw new InvalidArgumentException("拒绝操作非 related_paragraphs 表族的表:$table");
+    }
+
+    return $table;
+}
+
+function rp_json_write(string $path, mixed $data): void
+{
+    file_put_contents(
+        $path,
+        json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n"
+    );
+}
+
+function rp_json_read(string $path): mixed
+{
+    if (! file_exists($path)) {
+        throw new RuntimeException("找不到文件:$path");
+    }
+
+    return json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
+}

+ 6 - 0
openapi/public/assets/protocol/main.yaml

@@ -21,9 +21,15 @@ servers:
   -
     url: 'https://www.wikipali.org/api'
     description: 生产
+  -
+    url: 'https://next.wikipali.org/api'
+    description: 生产预览
   -
     url: 'https://www.wikipali.cc/api'
     description: 生产(中国)
+  -
+    url: 'https://next.wikipali.cc/api'
+    description: 生产预览(中国)
 components:
   securitySchemes:
     bearerAuth:

+ 37 - 34
openapi/scripts/generate.php

@@ -22,19 +22,19 @@ use PhpParser\ParserFactory;
 use Symfony\Component\Yaml\Yaml;
 
 $root = dirname(__DIR__, 2);
-$api = $root.'/api-v13';
-require $api.'/vendor/autoload.php';
+$api = $root . '/api-v13';
+require $api . '/vendor/autoload.php';
 
-$protocol = dirname(__DIR__).'/public/assets/protocol';
-$autoDir = $protocol.'/resources/auto';
-$overrideDir = $protocol.'/overrides';
+$protocol = dirname(__DIR__) . '/public/assets/protocol';
+$autoDir = $protocol . '/resources/auto';
+$overrideDir = $protocol . '/overrides';
 
 $parser = (new ParserFactory)->createForNewestSupportedVersion();
 $finder = new NodeFinder;
 
 /* ---------------------------------------------------------------- 路由 */
 
-exec('cd '.escapeshellarg($api).' && php artisan route:list --json 2>/dev/null', $out, $code);
+exec('cd ' . escapeshellarg($api) . ' && php artisan route:list --json 2>/dev/null', $out, $code);
 $routes = json_decode(implode('', $out), true);
 if (! is_array($routes)) {
     fwrite(STDERR, "无法读取 php artisan route:list --json\n");
@@ -48,13 +48,13 @@ function parseController(string $class, string $method): array
 {
     global $api, $parser, $finder, $methodCache;
 
-    $key = $class.'::'.$method;
+    $key = $class . '::' . $method;
     if (isset($methodCache[$key])) {
         return $methodCache[$key];
     }
     $empty = ['summary' => null, 'description' => null, 'params' => [], 'validate' => [], 'resource' => null, 'collection' => false, 'tags' => ['params' => [], 'deprecated' => false, 'unauthenticated' => false, 'responses' => [], 'meta' => []]];
 
-    $file = $api.'/'.str_replace(['App\\', '\\'], ['app/', '/'], $class).'.php';
+    $file = $api . '/' . str_replace(['App\\', '\\'], ['app/', '/'], $class) . '.php';
     if (! is_file($file)) {
         return $methodCache[$key] = $empty;
     }
@@ -103,7 +103,7 @@ function parseController(string $class, string $method): array
             }
             // 上一行是带标签的参数说明时,缩进的续行并入该参数的描述
             if ($tagLines && preg_match('/^@(queryParam|bodyParam|urlParam)\b/', end($tagLines))) {
-                $tagLines[count($tagLines) - 1] .= ' '.$line;
+                $tagLines[count($tagLines) - 1] .= ' ' . $line;
 
                 continue;
             }
@@ -120,7 +120,7 @@ function parseController(string $class, string $method): array
             'Show the form for creating a new resource.',
             'Show the form for editing the specified resource.',
         ];
-        $lines = array_values(array_filter($lines, fn ($l) => ! in_array($l, $stubs, true)));
+        $lines = array_values(array_filter($lines, fn($l) => ! in_array($l, $stubs, true)));
         if ($lines) {
             $info['summary'] = array_shift($lines);
             $info['description'] = $lines ? implode("\n", $lines) : null;
@@ -405,11 +405,11 @@ function resourceSchema(?string $class): ?array
         return null;
     }
     $class = ltrim($class, '\\');
-    $file = $api.'/'.str_replace(['App\\', '\\'], ['app/', '/'], $class).'.php';
+    $file = $api . '/' . str_replace(['App\\', '\\'], ['app/', '/'], $class) . '.php';
     // 控制器里通常写的是短类名(new ChannelResource(...)),按全名拼不出路径,
     // 回落到 app/Http/Resources 下同名文件
     if (! is_file($file) && ! str_contains($class, '\\')) {
-        $file = $api.'/app/Http/Resources/'.$class.'.php';
+        $file = $api . '/app/Http/Resources/' . $class . '.php';
     }
     if (! is_file($file)) {
         return null;
@@ -540,7 +540,7 @@ function shapeToProperties(string $inner): array
         }
         $schema = phpTypeToSchema(trim($type));
         if ($optional) {
-            $schema['description'] = trim(($schema['description'] ?? '').' 仅在部分查询口径下返回。');
+            $schema['description'] = trim(($schema['description'] ?? '') . ' 仅在部分查询口径下返回。');
         }
         $props[$key] = $schema;
     }
@@ -667,7 +667,7 @@ function metaSchema(array $info): array
 
 /* ------------------------------------------------------- 生成 operation */
 
-$listParams = Yaml::parseFile($protocol.'/resources/list_query.yaml');
+$listParams = Yaml::parseFile($protocol . '/resources/list_query.yaml');
 
 function buildOperation(array $route, string $httpMethod): array
 {
@@ -681,8 +681,8 @@ function buildOperation(array $route, string $httpMethod): array
     $op = [
         'summary' => $info['summary'] ?: defaultSummary($method, $route['uri']),
         'tags' => [$tag],
-        'operationId' => strtolower($httpMethod).'_'.preg_replace('/[^a-zA-Z0-9]+/', '_', $route['uri']),
-        'description' => trim(($info['description'] ?? '')."\n\n实现:`{$short}@{$method}`"),
+        'operationId' => strtolower($httpMethod) . '_' . preg_replace('/[^a-zA-Z0-9]+/', '_', $route['uri']),
+        'description' => trim(($info['description'] ?? '') . "\n\n实现:`{$short}@{$method}`"),
     ];
 
     if (! empty($info['tags']['deprecated'])) {
@@ -724,7 +724,7 @@ function buildOperation(array $route, string $httpMethod): array
     foreach ($names as $name) {
         $p = $info['params'][$name] ?? ['type' => 'string', 'default' => null];
         $doc = $documented[$name] ?? null;
-        if (preg_match('/\{'.preg_quote($name, '/').'\??\}/', $route['uri'])) {
+        if (preg_match('/\{' . preg_quote($name, '/') . '\??\}/', $route['uri'])) {
             continue;
         }
         // 文档注释把它标成 body 参数时,不再当查询参数输出
@@ -777,7 +777,7 @@ function buildOperation(array $route, string $httpMethod): array
         $props = [];
         $required = [];
         foreach ($info['validate'] as $field => $rule) {
-            $schema = ['type' => $rule['type'], 'description' => '规则:'.$rule['raw']];
+            $schema = ['type' => $rule['type'], 'description' => '规则:' . $rule['raw']];
             if (! empty($rule['enum'])) {
                 $schema['enum'] = $rule['enum'];
             }
@@ -980,7 +980,7 @@ foreach ($routes as $route) {
         if (in_array($httpMethod, ['HEAD', 'OPTIONS'], true)) {
             continue;
         }
-        $openapiPath = '/'.preg_replace('#^api/#', '', $uri);
+        $openapiPath = '/' . preg_replace('#^api/#', '', $uri);
         $openapiPath = preg_replace('/\{(\w+)\?\}/', '{$1}', $openapiPath);
         $paths[$openapiPath][strtolower($httpMethod)] = buildOperation($route, $httpMethod);
     }
@@ -1012,7 +1012,7 @@ function pathSlug(string $path): string
 
 $overridden = 0;
 foreach ($paths as $path => $ops) {
-    $file = $overrideDir.'/'.pathSlug($path).'.yaml';
+    $file = $overrideDir . '/' . pathSlug($path) . '.yaml';
     if (is_file($file)) {
         $paths[$path] = deepMerge($ops, Yaml::parseFile($file) ?: []);
         $overridden++;
@@ -1021,7 +1021,7 @@ foreach ($paths as $path => $ops) {
 
 /* ------------------------------------------------------------- 写文件 */
 
-foreach (glob($autoDir.'/*.yaml') ?: [] as $stale) {
+foreach (glob($autoDir . '/*.yaml') ?: [] as $stale) {
     unlink($stale);
 }
 @mkdir($autoDir, 0o755, true);
@@ -1029,11 +1029,11 @@ foreach (glob($autoDir.'/*.yaml') ?: [] as $stale) {
 
 $refs = [];
 foreach ($paths as $path => $ops) {
-    $name = pathSlug($path).'.yaml';
+    $name = pathSlug($path) . '.yaml';
     file_put_contents(
-        $autoDir.'/'.$name,
-        "# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/{$name}\n".
-        Yaml::dump($ops, 12, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)
+        $autoDir . '/' . $name,
+        "# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/{$name}\n" .
+            Yaml::dump($ops, 12, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)
     );
     $refs[$path] = ['$ref' => "./resources/auto/{$name}"];
 }
@@ -1042,8 +1042,8 @@ $main = [
     'openapi' => '3.0.3',
     'info' => [
         'title' => 'MINT API',
-        'description' => "International Academy Of Pali Tipitaka(国际巴利三藏学院)\n\n".
-            "由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。\n".
+        'description' => "International Academy Of Pali Tipitaka(国际巴利三藏学院)\n\n" .
+            "由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。\n" .
             "响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。",
         'version' => date('Y.n.j'),
     ],
@@ -1052,7 +1052,9 @@ $main = [
         ['url' => '/api', 'description' => '同源(预览页里 Try it out 走 vite 代理,避免跨域)'],
         ['url' => 'http://127.0.0.1:8000/api', 'description' => '本地开发(直连,浏览器里会被 CORS 挡住)'],
         ['url' => 'https://staging.wikipali.org/api', 'description' => '测试'],
+        ['url' => 'https://next.wikipali.org/api', 'description' => '生产预览'],
         ['url' => 'https://www.wikipali.org/api', 'description' => '生产'],
+        ['url' => 'https://next.wikipali.cc/api', 'description' => '生产预览(中国)'],
         ['url' => 'https://www.wikipali.cc/api', 'description' => '生产(中国)'],
     ],
     'components' => [
@@ -1090,8 +1092,8 @@ $main = [
         'schemas' => [
             'ProblemDetails' => [
                 'type' => 'object',
-                'description' => "RFC 9457 Problem Details,v3 端点的错误响应体。\n".
-                    "由 bootstrap/app.php 的异常处理器统一渲染,控制器不自己拼。\n".
+                'description' => "RFC 9457 Problem Details,v3 端点的错误响应体。\n" .
+                    "由 bootstrap/app.php 的异常处理器统一渲染,控制器不自己拼。\n" .
                     'media type 是 application/problem+json。',
                 'properties' => [
                     'type' => ['type' => 'string', 'description' => '问题类型标识,形如 urn:problem:not-found', 'example' => 'urn:problem:not-found'],
@@ -1109,8 +1111,8 @@ $main = [
             ],
             'PaginationMeta' => [
                 'type' => 'object',
-                'description' => "v3 列表接口的分页信息,由 Laravel paginator 生成。\n".
-                    "刻意不含 links / path:那些是 APP_URL 拼出的绝对地址,反代下会拼错,前端也用不到。\n".
+                'description' => "v3 列表接口的分页信息,由 Laravel paginator 生成。\n" .
+                    "刻意不含 links / path:那些是 APP_URL 拼出的绝对地址,反代下会拼错,前端也用不到。\n" .
                     '手工分页的接口会附加自己的字段(如 has_more / first_para / page_size)。',
                 'properties' => [
                     'current_page' => ['type' => 'integer'],
@@ -1141,11 +1143,12 @@ $mainYaml = Yaml::dump($main, 8, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
 // 空数组被 dump 成 {  },而 security requirement 的值必须是数组
 $mainYaml = str_replace('bearerAuth: {  }', 'bearerAuth: []', $mainYaml);
 file_put_contents(
-    $protocol.'/main.yaml',
-    "# 由 openapi/scripts/generate.php 生成,请勿手改\n".$mainYaml
+    $protocol . '/main.yaml',
+    "# 由 openapi/scripts/generate.php 生成,请勿手改\n" . $mainYaml
 );
 
-printf("生成 %d 个 path,%d 个 operation,%d 个 path 应用了 overrides\n",
+printf(
+    "生成 %d 个 path,%d 个 operation,%d 个 path 应用了 overrides\n",
     count($paths),
     array_sum(array_map('count', $paths)),
     $overridden