Browse Source

Merge pull request #2362 from visuddhinanda/development

v12->v13
visuddhinanda 2 weeks ago
parent
commit
ed6a9e0092
100 changed files with 9758 additions and 1501 deletions
  1. 0 2
      api-v12/app/Console/Commands/ExportPaliSynonyms.php
  2. 0 289
      api-v12/app/Console/Commands/IndexPaliText.php
  3. 202 0
      api-v12/app/Console/Commands/IndexTerm.php
  4. 444 0
      api-v12/app/Console/Commands/IndexTipitaka.php
  5. 1 1
      api-v12/app/Console/Commands/InitSystemChannel.php
  6. 3 3
      api-v12/app/Console/Commands/TestAITerm.php
  7. 81 0
      api-v12/app/Console/Commands/UpgradeAITerm.php
  8. 4 3
      api-v12/app/Console/Commands/UsersDesensitize.php
  9. 11 0
      api-v12/app/DTO/Search/AggregationDTO.php
  10. 10 0
      api-v12/app/DTO/Search/AggregationsDTO.php
  11. 7 0
      api-v12/app/DTO/Search/BucketDTO.php
  12. 100 29
      api-v12/app/DTO/Search/HitItemDTO.php
  13. 31 2
      api-v12/app/Http/Api/TemplateRender.php
  14. 29 5
      api-v12/app/Http/Controllers/CorpusController.php
  15. 100 131
      api-v12/app/Http/Controllers/Library/BookController.php
  16. 230 0
      api-v12/app/Http/Controllers/Library/HomeController.php
  17. 22 18
      api-v12/app/Http/Controllers/Library/SearchController.php
  18. 31 175
      api-v12/app/Http/Controllers/Library/TipitakaController.php
  19. 200 66
      api-v12/app/Http/Controllers/Library/WikiController.php
  20. 5 8
      api-v12/app/Http/Controllers/SearchPlusController.php
  21. 21 17
      api-v12/app/Http/Controllers/SearchSuggestController.php
  22. 4 3
      api-v12/app/Http/Resources/TermResource.php
  23. 30 0
      api-v12/app/Models/PaliText.php
  24. 1 1
      api-v12/app/Models/TagMap.php
  25. 250 0
      api-v12/app/Services/AIAssistant/AITermService.php
  26. 0 133
      api-v12/app/Services/AITermService.php
  27. 515 310
      api-v12/app/Services/OpenSearchService.php
  28. 18 7
      api-v12/app/Services/SearchPaliDataService.php
  29. 67 3
      api-v12/app/Services/TermService.php
  30. 1 1
      api-v12/config/mint.php
  31. 254 0
      api-v12/config/taxonomy.php
  32. 105 0
      api-v12/documents/category.md
  33. 61 0
      api-v12/documents/opensearch.md
  34. 9 0
      api-v12/resources/css/components/_search-input.css
  35. 42 0
      api-v12/resources/css/modules/_tipitaka.css
  36. 505 66
      api-v12/resources/css/modules/_wiki.css
  37. 4 3
      api-v12/resources/js/app.js
  38. 91 0
      api-v12/resources/js/search-suggest.js
  39. 31 19
      api-v12/resources/views/components/ui/search-input.blade.php
  40. 95 0
      api-v12/resources/views/components/wiki/entry-actions.blade.php
  41. 5 14
      api-v12/resources/views/components/wiki/quality-badge.blade.php
  42. 0 50
      api-v12/resources/views/components/wiki/search-box.blade.php
  43. 10 3
      api-v12/resources/views/components/wiki/search-result-card.blade.php
  44. 14 0
      api-v12/resources/views/components/wiki/sub-category.blade.php
  45. 14 0
      api-v12/resources/views/components/wiki/term-link.blade.php
  46. 9 12
      api-v12/resources/views/library/anthology/index.blade.php
  47. 2 18
      api-v12/resources/views/library/book/read.blade.php
  48. 73 40
      api-v12/resources/views/library/search.blade.php
  49. 1 1
      api-v12/resources/views/library/tipitaka/category.blade.php
  50. 21 7
      api-v12/resources/views/library/tipitaka/show.blade.php
  51. 4 3
      api-v12/resources/views/library/wiki/home.blade.php
  52. 53 3
      api-v12/resources/views/library/wiki/index.blade.php
  53. 46 40
      api-v12/resources/views/library/wiki/layouts/app.blade.php
  54. 9 4
      api-v12/resources/views/library/wiki/show.blade.php
  55. 5 4
      api-v12/routes/web.php
  56. 16 7
      api-v13/.env.example
  57. 168 0
      api-v13/app/Console/Commands/AiTranslate.php
  58. 77 0
      api-v13/app/Console/Commands/CacheDictPreference.php
  59. 120 0
      api-v13/app/Console/Commands/CacheWbwPreference.php
  60. 45 0
      api-v13/app/Console/Commands/ClearEmbeddingsCache.php
  61. 180 0
      api-v13/app/Console/Commands/CopyUserBook.php
  62. 114 0
      api-v13/app/Console/Commands/CreateMyHanCrop.php
  63. 75 0
      api-v13/app/Console/Commands/CreateOpenSearchIndex.php
  64. 100 0
      api-v13/app/Console/Commands/ExportAiPaliWordToken.php
  65. 209 0
      api-v13/app/Console/Commands/ExportAiTrainingData.php
  66. 165 0
      api-v13/app/Console/Commands/ExportArticle.php
  67. 87 0
      api-v13/app/Console/Commands/ExportChannel.php
  68. 314 0
      api-v13/app/Console/Commands/ExportChapter.php
  69. 100 0
      api-v13/app/Console/Commands/ExportChapterIndex.php
  70. 69 0
      api-v13/app/Console/Commands/ExportCreateDb.php
  71. 259 0
      api-v13/app/Console/Commands/ExportDiscussion.php
  72. 109 0
      api-v13/app/Console/Commands/ExportFtsPali.php
  73. 102 0
      api-v13/app/Console/Commands/ExportGlossary.php
  74. 68 0
      api-v13/app/Console/Commands/ExportIKPaliTeam.php
  75. 219 0
      api-v13/app/Console/Commands/ExportNissaya.php
  76. 125 0
      api-v13/app/Console/Commands/ExportOffline.php
  77. 91 0
      api-v13/app/Console/Commands/ExportPaliSynonyms.php
  78. 87 0
      api-v13/app/Console/Commands/ExportPalitext.php
  79. 131 0
      api-v13/app/Console/Commands/ExportSentence.php
  80. 80 0
      api-v13/app/Console/Commands/ExportTag.php
  81. 75 0
      api-v13/app/Console/Commands/ExportTagmap.php
  82. 100 0
      api-v13/app/Console/Commands/ExportTerm.php
  83. 203 0
      api-v13/app/Console/Commands/ExportZip.php
  84. 277 0
      api-v13/app/Console/Commands/ExportZip2.php
  85. 147 0
      api-v13/app/Console/Commands/ImportArticle.php
  86. 213 0
      api-v13/app/Console/Commands/ImportArticleMap.php
  87. 41 0
      api-v13/app/Console/Commands/IndexOpenSearch.php
  88. 202 0
      api-v13/app/Console/Commands/IndexTerm.php
  89. 444 0
      api-v13/app/Console/Commands/IndexTipitaka.php
  90. 93 0
      api-v13/app/Console/Commands/InitCommentary.php
  91. 156 0
      api-v13/app/Console/Commands/InitCs6sentence.php
  92. 58 0
      api-v13/app/Console/Commands/InitDependence.php
  93. 148 0
      api-v13/app/Console/Commands/InitSystemChannel.php
  94. 103 0
      api-v13/app/Console/Commands/InitSystemDict.php
  95. 66 0
      api-v13/app/Console/Commands/Install.php
  96. 42 0
      api-v13/app/Console/Commands/InstallPaliSent.php
  97. 83 0
      api-v13/app/Console/Commands/InstallPaliSeries.php
  98. 42 0
      api-v13/app/Console/Commands/InstallPaliSim.php
  99. 156 0
      api-v13/app/Console/Commands/InstallPaliText.php
  100. 118 0
      api-v13/app/Console/Commands/InstallWbwTemplate.php

+ 0 - 2
api-v12/app/Console/Commands/ExportPaliSynonyms.php

@@ -6,8 +6,6 @@ use Illuminate\Console\Command;
 use App\Http\Api\DictApi;
 use App\Http\Api\DictApi;
 use App\Models\UserDict;
 use App\Models\UserDict;
 use App\Models\DhammaTerm;
 use App\Models\DhammaTerm;
-use Illuminate\Support\Facades\Redis;
-use Illuminate\Support\Facades\Log;
 
 
 class ExportPaliSynonyms extends Command
 class ExportPaliSynonyms extends Command
 {
 {

+ 0 - 289
api-v12/app/Console/Commands/IndexPaliText.php

@@ -1,289 +0,0 @@
-<?php
-
-namespace App\Console\Commands;
-
-use Illuminate\Console\Command;
-use App\Services\SearchPaliDataService;
-use App\Services\OpenSearchService;
-use App\Services\SummaryService;
-use App\Services\TagService;
-use Illuminate\Support\Facades\Log;
-use App\Models\PaliText;
-
-class IndexPaliText extends Command
-{
-    /**
-     * The name and signature of the console command.
-     * php artisan opensearch:index-pali 93 --para=6
-     * @var string
-     */
-    protected $signature = 'opensearch:index-pali {book : The book ID to index data for}
-    {--test}
-    {--para= : index paragraph No. omit to all}
-    {--summary=on}
-    {--resume}
-    {--granularity= : The granularity to index (paragraph, sutta, sentence; omit to index all)}';
-
-    /**
-     * The console command description.
-     *
-     * @var string
-     */
-    protected $description = 'Index Pali data into OpenSearch for a specified book and optional granularity (all granularities if not specified)';
-
-    protected $searchPaliDataService;
-    protected $openSearchService;
-    protected $summaryService;
-    protected $tagService;
-    private $isTest = false;
-    private $summary = false;
-
-    /**
-     * Create a new command instance.
-     *
-     * @return void
-     */
-    public function __construct(
-        SearchPaliDataService $searchPaliDataService,
-        OpenSearchService $openSearchService,
-        SummaryService $summaryService,
-        TagService $tagService
-    ) {
-        parent::__construct();
-        $this->searchPaliDataService = $searchPaliDataService;
-        $this->openSearchService = $openSearchService;
-        $this->summaryService = $summaryService;
-        $this->tagService = $tagService;
-    }
-
-    /**
-     * Execute the console command.
-     *
-     * @return int
-     */
-    public function handle()
-    {
-        $book = (int)$this->argument('book');
-        $granularity = $this->option('granularity');
-        $paragraph = $this->option('para');
-        $this->summary = $this->option('summary') === 'on';
-
-        if ($this->option('test')) {
-            $this->isTest = true;
-            $this->info('test mode');
-        }
-
-
-        try {
-            // Test OpenSearch connection
-            [$connected, $message] = $this->openSearchService->testConnection();
-            if (!$connected) {
-                $this->error($message);
-                Log::error($message);
-                return 1;
-            }
-            $overallStatus = 0; // Track overall command status (0 for success, 1 for any failure)
-            $maxBookId = PaliText::max('book');
-            if ($book === 0) {
-                $booksId = range(1, $maxBookId);
-            } else if ($this->option('resume')) {
-                $booksId = range($book, $maxBookId);
-            } else {
-                $booksId = [$book];
-            }
-            foreach ($booksId as $key => $bookId) {
-                $this->indexPaliParagraphs($bookId, $paragraph);
-            }
-
-            return $overallStatus;
-        } catch (\Exception $e) {
-            $this->error("Failed to index Pali data: " . $e->getMessage());
-            Log::error("Failed to index Pali data for book: $book, granularity: " . ($granularity ?: 'all'), ['error' => $e]);
-            return 1;
-        }
-    }
-
-    /**
-     *
-     */
-    protected function indexPaliParagraph($paraInfo, $paraContent, $related_id, array $category)
-    {
-        $paraId = $paraInfo['book'] . '_' . $paraInfo['paragraph'];
-        $resource_id = $paraInfo['uid'];
-        $path = json_decode($paraInfo['path']);
-        if (is_array($path) && count($path) > 0) {
-            $title = end($path)->title;
-        } else {
-            $title = '';
-        }
-        $document = [
-            'id' => "pali_para_{$paraId}",
-            'resource_id' => $resource_id, // Use uid from getPaliData for resource_id
-            'resource_type' => 'original_text',
-            'title' => [
-                'pali' => $title,
-            ],
-            'summary' => [
-                'text' => $this->summary  ? $this->summaryService->summarize($paraContent['markdown']) : ''
-            ],
-            'content' => [
-                'pali' => $paraContent['markdown'],
-                'suggest' => $paraContent['words'],
-            ],
-            'bold_single' => implode(' ', $paraContent['bold1']),
-            'bold_multi' => implode(' ', array_merge($paraContent['bold2'], $paraContent['bold3'])),
-            'related_id' => $related_id,
-            'category' => $category, // Assuming Pali paragraphs are sutta; adjust as needed
-            'language' => 'pali',
-            'updated_at' => now()->toIso8601String(),
-            'granularity' => 'paragraph',
-            'path' => $this->getPathTitle($path),
-        ];
-        if ($paraInfo['level'] < 8) {
-            $document['title']['suggest'] = $paraContent['words'];
-        }
-        if ($this->isTest) {
-            $this->info($document['title']['pali']);
-            $this->info($document['summary']['text']);
-        } else {
-            $this->openSearchService->create($document['id'], $document);
-        }
-        return;
-    }
-
-    /**
-     *
-     */
-    protected function indexPaliSession($paraInfo, $contents, $currChapter, $related_id)
-    {
-        $markdown = [];
-        $text = [];
-        $bold_single = [];
-        $bold_multi = [];
-        foreach ($contents as $key => $content) {
-            $markdown[] = $content['markdown'];
-            $text[] = $content['text'];
-            $bold_single = array_merge($bold_single, $content['bold1']);
-            $bold_multi = array_merge($bold_multi, $content['bold2'], $content['bold3']);
-        }
-        $document = [
-            'id' => "pali_session_{$related_id}",
-            'resource_id' => $paraInfo['uid'], // Use uid from getPaliData for resource_id
-            'resource_type' => 'original_text',
-            'title' => [
-                'pali' => "{$currChapter} paragraph {$paraInfo['paragraph']}"
-            ],
-            'summary' => [
-                'text' => $this->summary ? $this->summaryService->summarize($content['markdown']) : ''
-            ],
-            'content' => [
-                'pali' => implode("\n\n", $markdown),
-            ],
-            'bold_single' => implode(" ", $bold_single),
-            'bold_multi' => implode(" ", $bold_multi),
-            'related_id' => $related_id,
-            'category' => 'pali', // Assuming Pali paragraphs are sutta; adjust as needed
-            'language' => 'pali',
-            'updated_at' => now()->toIso8601String(),
-            'granularity' => 'session',
-            'path' => $this->getPathTitle(json_decode($paraInfo['path'])),
-        ];
-        if ($this->isTest) {
-            $this->info($document['title']['pali']);
-            $this->info($document['summary']['text']);
-        } else {
-            $this->openSearchService->create($document['id'], $document);
-        }
-        return;
-    }
-
-    private function getPathTitle(array $input)
-    {
-        $output = [];
-        foreach ($input as $key => $node) {
-            $output[] = $node->title;
-        }
-        return implode('/', $output);
-    }
-    /**
-     * Index Pali paragraphs for a given book.
-     *
-     * @param int $book
-     * @return int
-     */
-    protected function indexPaliParagraphs($book, $paragraph = null)
-    {
-        $this->info("Starting to index paragraphs for book: $book");
-        $total = 0;
-        if ($paragraph) {
-            $paragraphs = PaliText::where('book', $book)
-                ->where('paragraph', $paragraph)
-                ->orderBy('paragraph')->cursor();
-        } else {
-            $paragraphs = PaliText::where('book', $book)
-                ->orderBy('paragraph')->cursor();
-        }
-        $bookUid = PaliText::where('book', $book)->where('level', 1)->first()->uid;
-        $category = $this->tagService->getTagsName($bookUid);
-        $headings = [];
-        $currChapterTitle = '';
-        $commentaryId = '';
-        $currSession = [];
-        foreach ($paragraphs as $key => $para) {
-            $total++;
-            if ($para->level < 8) {
-                $currChapterTitle = $para->toc;
-            }
-            if ($para->class === 'nikaya') {
-                $nikaya = $para->text;
-            }
-            $paraContent = $this->searchPaliDataService
-                ->getParaContent($para['book'], $para['paragraph']);
-            if (!empty($commentaryId)) {
-                $currSession[] = $paraContent;
-            }
-            if (isset($paraContent['commentary'])) {
-                if (!empty($commentaryId)) {
-                    //保存 session
-                    $this->indexPaliSession($para->toArray(), $currSession, $currChapterTitle, $commentaryId);
-                    $currSession = [];
-                }
-                $commentaryId = $paraContent['commentary'];
-            }
-            $this->indexPaliParagraph($para->toArray(), $paraContent, $commentaryId, $category);
-            $this->info("{$para['book']}-[{$para['paragraph']}]-[{$commentaryId}]");
-            usleep(10000);
-        }
-
-        $this->info("Successfully indexed $total paragraphs for book: $book");
-        Log::info("Indexed $total paragraphs for book: $book");
-
-        return 0;
-    }
-
-    /**
-     * Index Pali suttas for a given book (placeholder for future implementation).
-     *
-     * @param int $book
-     * @return int
-     */
-    protected function indexPaliSutta($book)
-    {
-        $this->warn("Sutta indexing is not yet implemented for book: $book");
-        Log::warning("Sutta indexing not implemented for book: $book");
-        return 1;
-    }
-
-    /**
-     * Index Pali sentences for a given book (placeholder for future implementation).
-     *
-     * @param int $book
-     * @return int
-     */
-    protected function indexPaliSentences($book)
-    {
-        $this->warn("Sentence indexing is not yet implemented for book: $book");
-        Log::warning("Sentence indexing not implemented for book: $book");
-        return 1;
-    }
-}

+ 202 - 0
api-v12/app/Console/Commands/IndexTerm.php

@@ -0,0 +1,202 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\DhammaTerm;
+use Illuminate\Console\Command;
+use App\Services\OpenSearchService;
+use App\Services\TermService;
+use Illuminate\Support\Facades\Log;
+
+class IndexTerm extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     *
+     * @example
+     *   php artisan opensearch:index-term
+     *   php artisan opensearch:index-term --word=anomadassī
+     *   php artisan opensearch:index-term --test
+     */
+    protected $signature = 'opensearch:index-term
+        {--test}
+        {--word= : 指定单个词条进行索引,省略则索引全部}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Index Term data into OpenSearch';
+
+    /** @var bool 是否为测试模式(只打印,不写入 OpenSearch) */
+    private bool $isTest = false;
+
+    /**
+     * Create a new command instance.
+     */
+    public function __construct(
+        protected OpenSearchService $openSearchService,
+        protected TermService $termService,
+    ) {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * 遍历所有(或指定)DhammaTerm,逐条构建文档并写入 OpenSearch。
+     * 测试模式下(--test)只打印文档内容,不执行写入。
+     *
+     * @return int  0 表示成功,1 表示失败
+     */
+    public function handle(): int
+    {
+        $word = $this->option('word');
+
+        if ($this->option('test')) {
+            $this->isTest = true;
+            $this->info('test mode');
+        }
+
+        try {
+            [$connected, $message] = $this->openSearchService->testConnection();
+            if (!$connected) {
+                $this->error($message);
+                Log::error($message);
+                return 1;
+            }
+
+            $total = DhammaTerm::count();
+            $terms = DhammaTerm::select(['guid', 'word'])->orderBy('updated_at', 'asc');
+
+            if ($word) {
+                $terms = $terms->where('word', $word);
+            }
+
+            $overallStatus = 0;
+
+            foreach ($terms->cursor() as $key => $term) {
+                $percent = (int) (($key * 100) / $total);
+                $this->info("[{$percent}%]-{$key}  " . $term->word);
+                $this->indexTerm($term->guid);
+            }
+
+            return $overallStatus;
+        } catch (\Exception $e) {
+            $this->error('Failed to index Term data: ' . $e->getMessage());
+            Log::error('Failed to index Term data', ['error' => $e]);
+            return 1;
+        }
+    }
+
+    /**
+     * 构建单条词条文档并写入 OpenSearch
+     *
+     * 文档结构遵循新版 mapping:
+     *   title.text.pali / title.text.zh  → 全文检索
+     *   title.suggest.pali / title.suggest.zh → 自动建议
+     *   content.text.pali / content.text.zh   → 正文内容
+     *
+     * @param  string  $id  DhammaTerm 的 guid
+     * @return void
+     */
+    protected function indexTerm(string $id): void
+    {
+        $termData    = $this->termService->find($id, 'text');
+        $channelName = $termData['channel']['name'] ?? '';
+        $isCommunity = $this->termService->isCommunity($termData['channel_id']);
+        $content     = $termData['html'] ?? $termData['meaning'];
+
+        $categories = $this->extractCategories($termData['note'] ?? '');
+        $quality = $this->extractFirstQuality($termData['note'] ?? '');
+        $tags = [];
+        foreach ($categories as $key => $category) {
+            $tags[] = "category:{$category}";
+        }
+        if (!empty($quality)) {
+            $tags[] = "quality:{$quality}";
+        }
+        $document = [
+            'id'            => "term_{$id}",
+            'resource_id'   => $id,
+            'resource_type' => 'term',
+            'title'         => [
+                'text' => [
+                    'pali' => $termData['word'],
+                    'zh'   => $termData['meaning'],
+                ],
+                'suggest' => [
+                    'pali' => [$termData['word']],
+                    'zh'   => [$termData['meaning']],
+                ],
+            ],
+            'summary' => [
+                'text' => $termData['summary'] ?? '',
+            ],
+            'content'     => [],
+            'bold_single' => [$termData['meaning'], $termData['word']],
+            'related_id'  => $termData['word'],
+            'category'    => null,
+            'tags'        => $tags,
+            'language'    => $termData['language'],
+            'updated_at'  => now()->toIso8601String(),
+            'path'        => $termData['studio']['realName'] . "/{$channelName}",
+            'metadata' => ['channel' => $termData['channel_id']],
+        ];
+
+        // TODO: 补充语言判断,将内容放入对应的 text.pali 或 text.zh 字段
+        $plainText = strip_tags($content);
+        if (str_contains($termData['language'], 'zh')) {
+            $document['content']['text']['zh'] = $plainText;
+        } else {
+            $document['content']['text']['zh'] = $plainText;
+        }
+        $document['content']['display']    = $content;             // 展示
+
+        if ($this->isTest) {
+            $this->info($document['title']['text']['pali']);
+            $this->info($document['summary']['text']);
+        } else {
+            $this->openSearchService->create($document['id'], $document);
+        }
+    }
+
+    /**
+     * 提取 Markdown 中的 {{category|...}} 分类标签
+     *
+     * @param string $content
+     * @return array
+     */
+    private function extractCategories(string $content): array
+    {
+        if (empty($content)) {
+            return [];
+        }
+        preg_match_all('/\{\{category\|([^}]+)\}\}/u', $content, $matches);
+
+        return array_values(array_filter(array_map(
+            fn($item) => trim($item),
+            $matches[1] ?? []
+        )));
+    }
+
+    /**
+     * 提取 Markdown 中第一个 {{quality|...}} 标签内的内容
+     *
+     * @param string $content
+     * @return string
+     */
+    private function extractFirstQuality(string $content): string
+    {
+        if (empty($content)) {
+            return '';
+        }
+
+        preg_match('/\{\{quality\|([^}]+)\}\}/u', $content, $matches);
+
+        return isset($matches[1]) ? trim($matches[1]) : '';
+    }
+}

+ 444 - 0
api-v12/app/Console/Commands/IndexTipitaka.php

@@ -0,0 +1,444 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Services\SearchPaliDataService;
+use App\Services\OpenSearchService;
+use App\Services\SummaryService;
+use App\Services\TagService;
+use Illuminate\Support\Facades\Log;
+use App\Models\PaliText;
+use App\Models\Sentence;
+use App\Services\PaliContentService;
+use App\Http\Api\ChannelApi;
+use App\Models\ProgressChapter;
+
+class IndexTipitaka extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan opensearch:index-tipitaka 93 --para=6 --granularity=chapter
+     * @var string
+     */
+    protected $signature = 'opensearch:index-tipitaka {book : The book ID to index data for}
+    {--test}
+    {--para= : index paragraph No. omit to all}
+    {--summary=on}
+    {--resume}
+    {--granularity=all : The granularity to index (paragraph, sutta, sentence; omit to index all)}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Index Pali data into OpenSearch for a specified book and optional granularity (all granularities if not specified)';
+
+
+    private $isTest = false;
+    private $summary = false;
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected SearchPaliDataService $searchPaliDataService,
+        protected OpenSearchService $openSearchService,
+        protected SummaryService $summaryService,
+        protected TagService $tagService
+    ) {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $book = (int)$this->argument('book');
+        $granularity = $this->option('granularity');
+        $paragraph = $this->option('para');
+        $this->summary = $this->option('summary') === 'on';
+
+        if ($this->option('test')) {
+            $this->isTest = true;
+            $this->info('test mode');
+        }
+
+
+        try {
+            // Test OpenSearch connection
+            [$connected, $message] = $this->openSearchService->testConnection();
+            if (!$connected) {
+                $this->error($message);
+                Log::error($message);
+                return 1;
+            }
+            $overallStatus = 0; // Track overall command status (0 for success, 1 for any failure)
+            $maxBookId = PaliText::max('book');
+            if ($book === 0) {
+                $booksId = range(1, $maxBookId);
+            } else if ($this->option('resume')) {
+                $booksId = range($book, $maxBookId);
+            } else {
+                $booksId = [$book];
+            }
+            foreach ($booksId as $key => $bookId) {
+                if (
+                    $this->option('granularity') === 'chapter' ||
+                    $this->option('granularity') === 'all'
+                ) {
+                    $this->indexChapter($bookId);
+                }
+                if (
+                    $this->option('granularity') === 'paragraph' ||
+                    $this->option('granularity') === 'all'
+                ) {
+                    $this->indexTipitakaParagraph($bookId, $paragraph);
+                }
+            }
+
+            return $overallStatus;
+        } catch (\Exception $e) {
+            $this->error("Failed to index Pali data: " . $e->getMessage());
+            Log::error("Failed to index Pali data for book: $book, granularity: " . ($granularity ?: 'all'), ['error' => $e]);
+            return 1;
+        }
+    }
+
+    /**
+     * Index Pali paragraphs for a given book.
+     *
+     * @param int $book
+     * @return int
+     */
+    protected function indexTipitakaParagraph($book, $paragraph = null)
+    {
+        $this->info("Starting to index paragraphs for book: $book");
+        $total = 0;
+        if ($paragraph) {
+            $paragraphs = PaliText::where('book', $book)
+                ->where('paragraph', $paragraph)
+                ->orderBy('paragraph')->cursor();
+        } else {
+            $paragraphs = PaliText::where('book', $book)
+                ->orderBy('paragraph')->cursor();
+        }
+        $bookUid = PaliText::where('book', $book)->where('level', 1)->first()->uid;
+        $category = $this->tagService->getTagsName($bookUid);
+        $headings = [];
+        $currChapterTitle = '';
+        $commentaryId = '';
+        $currSession = [];
+        foreach ($paragraphs as $key => $para) {
+            $total++;
+            if ($para->level < 8) {
+                $currChapterTitle = $para->toc;
+            }
+            if ($para->class === 'nikaya') {
+                $nikaya = $para->text;
+            }
+            $paraContent = $this->searchPaliDataService
+                ->getParaContent($para['book'], $para['paragraph']);
+            if (!empty($commentaryId)) {
+                $currSession[] = $paraContent;
+            }
+            if (isset($paraContent['commentary'])) {
+                if (!empty($commentaryId)) {
+                    //保存 session
+                    $this->indexPaliSession($para->toArray(), $currSession, $currChapterTitle, $commentaryId);
+                    $currSession = [];
+                }
+                $commentaryId = $paraContent['commentary'];
+            }
+            $this->indexParagraph($para->toArray(), $paraContent, $commentaryId, $category);
+            $this->info("{$para['book']}-[{$para['paragraph']}]-[{$commentaryId}]");
+        }
+
+        $this->info("Successfully indexed $total paragraphs for book: $book");
+        Log::info("Indexed $total paragraphs for book: $book");
+
+        return 0;
+    }
+    /**
+     *
+     */
+    protected function indexParagraph($paraInfo, $paraContent, $related_id, array $category)
+    {
+        $paraId = $paraInfo['book'] . '-' . $paraInfo['paragraph'];
+        $resource_id = $paraInfo['uid'];
+        $path = json_decode($paraInfo['path']);
+        if (is_array($path) && count($path) > 0) {
+            $title = end($path)->title;
+        } else {
+            $title = '';
+        }
+        $document = [
+            'id' => "tipitaka_paragraph_pi_{$paraId}",
+            'resource_id' => $resource_id, // Use uid from getPaliData for resource_id
+            'resource_type' => 'tipitaka',
+            'title' => [
+                'text' => ['pali' => $title,],
+            ],
+            'summary' => [
+                'text' => $this->summary  ? $this->summaryService->summarize($paraContent['markdown']) : ''
+            ],
+            'content' => [
+                'text' => ['pali' => $paraContent['text']],
+                'suggest' => ['pali' => $paraContent['words']],
+            ],
+            'bold_single' => implode(' ', $paraContent['bold1']),
+            'bold_multi' => implode(' ', array_merge($paraContent['bold2'], $paraContent['bold3'])),
+            'related_id' => $paraId,
+            'category' => $category, // Assuming Pali paragraphs are sutta; adjust as needed
+            'language' => 'pi',
+            'updated_at' => now()->toIso8601String(),
+            'granularity' => 'paragraph',
+            'path' => $this->getPathTitle($path),
+        ];
+        if ($paraInfo['level'] < 8) {
+            $document['title']['suggest']['pali'] = $paraContent['words'];
+        }
+        if ($this->isTest) {
+            $this->info($document['title']['text']['pali']);
+            $this->info($document['summary']['text']);
+        } else {
+            $this->openSearchService->create($document['id'], $document);
+        }
+        return;
+    }
+
+    /**
+     *
+     */
+    protected function indexPaliSession($paraInfo, $contents, $currChapter, $related_id)
+    {
+        $markdown = [];
+        $text = [];
+        $bold_single = [];
+        $bold_multi = [];
+        foreach ($contents as $key => $content) {
+            $markdown[] = $content['markdown'];
+            $text[] = $content['text'];
+            $bold_single = array_merge($bold_single, $content['bold1']);
+            $bold_multi = array_merge($bold_multi, $content['bold2'], $content['bold3']);
+        }
+        $document = [
+            'id' => "pali_session_{$related_id}",
+            'resource_id' => $paraInfo['uid'], // Use uid from getPaliData for resource_id
+            'resource_type' => 'original_text',
+            'title' => [
+                'pali' => "{$currChapter} paragraph {$paraInfo['paragraph']}"
+            ],
+            'summary' => [
+                'text' => $this->summary ? $this->summaryService->summarize($content['markdown']) : ''
+            ],
+            'content' => [
+                'pali' => implode("\n\n", $markdown),
+            ],
+            'bold_single' => implode(" ", $bold_single),
+            'bold_multi' => implode(" ", $bold_multi),
+            'related_id' => $related_id,
+            'category' => 'pali', // Assuming Pali paragraphs are sutta; adjust as needed
+            'language' => 'pali',
+            'updated_at' => now()->toIso8601String(),
+            'granularity' => 'session',
+            'path' => $this->getPathTitle(json_decode($paraInfo['path'])),
+        ];
+        if ($this->isTest) {
+            $this->info($document['title']['pali']);
+            $this->info($document['summary']['text']);
+        } else {
+            $this->openSearchService->create($document['id'], $document);
+        }
+        return;
+    }
+
+
+
+    /**
+     * Index Pali suttas for a given book (placeholder for future implementation).
+     *
+     * @param int $book
+     * @return int
+     */
+    protected function indexChapter($book)
+    {
+        $this->info("Starting to index paragraphs for book: $book");
+        $total = 0;
+        $chapters = PaliText::where('book', $book)
+            ->where('level', '<', 8)
+            ->orderBy('paragraph')->get();
+        foreach ($chapters as $key => $chapter) {
+            if ($chapter->level === 1) {
+                $category = $this->tagService->getTagsName($chapter->uid);
+            }
+            /**
+             * 章节的起始位置算法
+             * 从章节的标题,到下一个章节的标题之间
+             */
+            $start = $chapter->paragraph;
+            if ($key === count($chapters) - 1) {
+                $end = PaliText::where('book', $book)
+                    ->orderBy('paragraph', 'desc')->first()
+                    ->value('paragraph');
+            } else {
+                $end = $chapters[$key + 1]->paragraph;
+            }
+            //获取这个段落之间的全部channel
+            $channels = Sentence::where('book_id', $book)
+                ->whereBetween('paragraph', [$start, $end])
+                ->select('channel_uid')
+                ->groupBy('channel_uid')->get();
+            $this->info("index chapter start={$start} end={$end}");
+
+            foreach ($channels as $key => $channel) {
+                $display = [];
+                $content = [];
+                $channelInfo = ChannelApi::getById($channel->channel_uid);
+                $this->info('channel =' . $channelInfo['name']);
+                if ($channelInfo['type'] === 'wbw') {
+                    $this->info('wbw channel skip');
+                    continue;
+                }
+                $paragraphsData = app(PaliContentService::class)->paragraphs(
+                    $book,
+                    $start,
+                    $end,
+                    [$channel->channel_uid],
+                    ['mode' => 'read', 'format' => 'html', 'original' => true]
+                );
+                //生成html数据
+
+                $title = '';
+                foreach ($paragraphsData as $key => $paragraph) {
+                    $translation = [];
+                    $original = [];
+                    foreach ($paragraph['children'] as $key => $sent) {
+                        if (isset($sent['translation'])) {
+                            foreach ($sent['translation'] as $key => $tran) {
+                                $curr = $tran['html'] ?? $tran['content'];
+                                $translation[] = "<span class='sentence'>{$curr}</span>";
+                                if ($tran['para'] === $start && !empty($curr)) {
+                                    $title = $curr;
+                                }
+                            }
+                        }
+                        if (
+                            isset($sent['origin']) ||
+                            is_array($sent['origin']) ||
+                            count($sent['origin']) > 0
+                        ) {
+                            $ori = $sent['origin'][0];
+                            $curr = $ori['html'] ?? $ori['content'];
+                            $original[] = "<span class='sentence origin'>{$curr}</span>";
+                            if (empty($title) && $ori['para'] === $start && !empty($curr)) {
+                                $title = $curr;
+                            }
+                        }
+                    }
+
+
+                    $level = $paragraph['para'] === $start ? $chapter->level : 0;
+                    $strOriginal = implode('', $original);
+                    $strTranslation = implode('', $translation);
+
+                    if ($level > 0) {
+                        $display[] = "<div><h{$level}>{$strOriginal}</h{$level}><h{$level}>{$strTranslation}</h{$level}></div>";
+                    } else {
+                        $display[] = "<div><p>{$strOriginal}</p><p>{$strTranslation}</p></div>";
+                    }
+
+                    if ($channelInfo['type'] === 'original') {
+                        $content[] = $strOriginal;
+                    } else {
+                        $content[] = $strTranslation;
+                    }
+                }
+                $this->chapterSave([
+                    'book' => $book,
+                    'para' => $start,
+                    'level' => $chapter->level,
+                    'channel' => $channel->channel_uid,
+                    'display' => implode('', $display),
+                    'content' => implode('', $content),
+                    'title' => strip_tags($title),
+                    'cat' => $category
+                ]);
+            }
+        }
+
+
+        return 0;
+    }
+
+    protected function chapterSave(array $param)
+    {
+        $progress = ProgressChapter::where('book', $param['book'])
+            ->where('para', $param['para'])
+            ->where('channel_id', $param['channel'])
+            ->first();
+        $channel = ChannelApi::getById($param['channel']);
+        $document = [
+            'id'            => "tipitaka_chapter_{$param['book']}-{$param['para']}_{$param['channel']}",
+            'resource_id'   => $progress ? $progress->uid : "{$param['book']}-{$param['para']}_{$param['channel']}",
+            'resource_type' => 'tipitaka',
+            'title'         => [],
+            'summary' => [
+                'text' => '',
+            ],
+            'content'     => [],
+            'related_id'  => "{$param['book']}-{$param['para']}",
+            'category'    => $param['cat'],
+            'language'    => $channel['lang'],
+            'updated_at'  => now()->toIso8601String(),
+            'granularity' => $param['level'] === 1 ? 'book' : 'chapter',
+        ];
+
+        // TODO: 补充语言判断,将内容放入对应的 text.pali 或 text.zh 字段
+        $plainText = strip_tags($param['content']);
+        $title = strip_tags($param['title']);
+        if (str_contains($channel['lang'], 'zh')) {
+            $document['content']['text']['zh'] = $plainText;
+            $document['title']['text']['zh'] = $title;
+        } else {
+            $document['content']['text']['pali'] = $plainText;
+            $document['title']['text']['pali'] = $title;
+        }
+        $document['content']['display']    = $param['display'];             // 展示
+
+        if ($this->isTest) {
+            $this->info($param['content']);
+        } else {
+            $this->openSearchService->create($document['id'], $document);
+            $this->info("create index {$document['id']} size=" . strlen($param['content']));
+        }
+    }
+    /**
+     * Index Pali sentences for a given book (placeholder for future implementation).
+     *
+     * @param int $book
+     * @return int
+     */
+    protected function indexPaliSentences($book)
+    {
+        $this->warn("Sentence indexing is not yet implemented for book: $book");
+        Log::warning("Sentence indexing not implemented for book: $book");
+        return 1;
+    }
+
+
+    private function getPathTitle(array $input)
+    {
+        $output = [];
+        foreach ($input as $key => $node) {
+            $output[] = $node->title;
+        }
+        return implode('/', $output);
+    }
+}

+ 1 - 1
api-v12/app/Console/Commands/InitSystemChannel.php

@@ -29,7 +29,7 @@ class InitSystemChannel extends Command
         ],
         ],
         [
         [
             "name" => '_System_Wbw_VRI_',
             "name" => '_System_Wbw_VRI_',
-            'type' => 'original',
+            'type' => 'wbw',
             'lang' => 'pali',
             'lang' => 'pali',
         ],
         ],
         [
         [

+ 3 - 3
api-v12/app/Console/Commands/TestAITerm.php

@@ -3,7 +3,7 @@
 namespace App\Console\Commands;
 namespace App\Console\Commands;
 
 
 use Illuminate\Console\Command;
 use Illuminate\Console\Command;
-use App\Services\AITermService;
+use App\Services\AIAssistant\AITermService;
 
 
 class TestAITerm extends Command
 class TestAITerm extends Command
 {
 {
@@ -29,10 +29,10 @@ class TestAITerm extends Command
         //
         //
         // ===== 创建 Service =====
         // ===== 创建 Service =====
         $service = app(AITermService::class);
         $service = app(AITermService::class);
-        $service->setModel('dd81ce6c-e9ff-46b2-b1af-947728ba996e');
 
 
         // ===== 执行 =====
         // ===== 执行 =====
-        $result = $service->create('f3ba16e5-862d-49c4-b5b0-39ab8b8ca4f4');
+        $result = $service->setModel('dd81ce6c-e9ff-46b2-b1af-947728ba996e')
+            ->update('f3ba16e5-862d-49c4-b5b0-39ab8b8ca4f4');
 
 
         // ===== 调试输出(建议保留)=====
         // ===== 调试输出(建议保留)=====
         dump($result);
         dump($result);

+ 81 - 0
api-v12/app/Console/Commands/UpgradeAITerm.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+
+use App\Services\AIModelService;
+use App\Services\TermService;
+use App\Services\AIAssistant\AITermService;
+
+use App\Http\Resources\AiModelResource;
+use App\Http\Controllers\AuthController;
+
+
+class UpgradeAITerm extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan upgrade:ai.term  --id=e07bf5b8-bd81-4f0a-9d2c-8e0128b954d7
+     * php artisan upgrade:ai.term
+     * @var string
+     */
+    protected $signature = 'upgrade:ai.term {--id=} {--resume} {--model=} ';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    protected AiModelResource $model;
+    protected $modelToken;
+    protected $workChannel;
+    protected $accessToken;
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected AIModelService $modelService,
+        protected TermService $termService,
+        protected AITermService $aiTermService
+    ) {
+
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if (!$this->option('model')) {
+            $modelId = $this->ask('请输入 llm model id');
+        } else {
+            $modelId = $this->option('model');
+        }
+        $this->aiTermService->setModel($modelId);
+        $this->model = $this->modelService->getModelById($modelId);
+        $this->info("model:{$this->model['model']}");
+        $this->modelToken = AuthController::getUserToken($modelId);
+
+        if ($this->option('id')) {
+            $terms = [['guid' => $this->option('id'), 'word' => 'word']];
+        } else {
+            $terms = $this->termService->getCommunityGlossary('zh-Hans')['items']->toArray();
+        }
+
+        foreach ($terms as $key => $term) {
+            $this->info("[{$term['word']}] running");
+            $result = $this->aiTermService->update($term['guid']);
+            $this->info("[{$term['word']}]content " . substr($result, 0, 30));
+        }
+
+        return 0;
+    }
+}

+ 4 - 3
api-v12/app/Console/Commands/UsersDesensitize.php

@@ -49,10 +49,11 @@ class UsersDesensitize extends Command
             $this->info('test mode');
             $this->info('test mode');
         } else {
         } else {
             $this->error('this is not test mode');
             $this->error('this is not test mode');
+            if (!$this->confirm("desensitize all users information?")) {
+                return 0;
+            }
         }
         }
-        if (!$this->confirm("desensitize all users information?")) {
-            return 0;
-        }
+
         $users = UserInfo::cursor();
         $users = UserInfo::cursor();
         $total = UserInfo::count();
         $total = UserInfo::count();
         $desensitized = 0;
         $desensitized = 0;

+ 11 - 0
api-v12/app/DTO/Search/AggregationDTO.php

@@ -23,4 +23,15 @@ class AggregationDTO
             buckets: array_map(fn($bucket) => BucketDTO::fromArray($bucket), $data['buckets']),
             buckets: array_map(fn($bucket) => BucketDTO::fromArray($bucket), $data['buckets']),
         );
         );
     }
     }
+    public function toArray(): array
+    {
+        return [
+            'doc_count_error_upper_bound' => $this->doc_count_error_upper_bound,
+            'sum_other_doc_count' => $this->sum_other_doc_count,
+            'buckets' => array_map(
+                fn(BucketDTO $bucket) => $bucket->toArray(),
+                $this->buckets
+            ),
+        ];
+    }
 }
 }

+ 10 - 0
api-v12/app/DTO/Search/AggregationsDTO.php

@@ -20,4 +20,14 @@ class AggregationsDTO
             category: AggregationDTO::fromArray($data['category']),
             category: AggregationDTO::fromArray($data['category']),
         );
         );
     }
     }
+
+    public function toArray(): array
+    {
+        return [
+            'granularity'   => $this->granularity->toArray(),
+            'resource_type' => $this->resource_type->toArray(),
+            'language'      => $this->language->toArray(),
+            'category'      => $this->category->toArray(),
+        ];
+    }
 }
 }

+ 7 - 0
api-v12/app/DTO/Search/BucketDTO.php

@@ -16,4 +16,11 @@ class BucketDTO
             doc_count: $data['doc_count'],
             doc_count: $data['doc_count'],
         );
         );
     }
     }
+    public function toArray(): array
+    {
+        return [
+            'key' => $this->key,
+            'doc_count' => $this->doc_count,
+        ];
+    }
 }
 }

+ 100 - 29
api-v12/app/DTO/Search/HitItemDTO.php

@@ -6,72 +6,143 @@ class HitItemDTO
 {
 {
     public function __construct(
     public function __construct(
         public string $id,
         public string $id,
+        public string $resId,
         public float $score,
         public float $score,
         public string $content,
         public string $content,
         public string $title,
         public string $title,
         public string $path,
         public string $path,
         public array $category,
         public array $category,
+        public array $tags,
         public string $highlight,
         public string $highlight,
-
+        public string $updated,
+        public string $type,
+        public string $language,
+        public ?string $display = null,
     ) {}
     ) {}
 
 
+    /**
+     * 从 OpenSearch hit 数组构建 DTO
+     *
+     * title 和 content 的拼接策略:
+     *   因为一条记录通常只有一种语言被赋值(pali 或 zh),
+     *   所以直接将 text.* 下所有非空值拼接输出,无需判断语言类型。
+     *
+     *   title:   取 title.text.pali + title.text.zh(跳过空值)
+     *   content: 取 content.text.pali + content.text.zh(跳过空值)
+     *   display: 取 content.display,列表页查询时因被 sourceExcludes 排除
+     *            通常为 null;详情页通过 get() 获取完整文档时才有值
+     *
+     * @param  array  $data  OpenSearch 单条 hit 原始数据
+     * @return static
+     */
     public static function fromArray(array $data): self
     public static function fromArray(array $data): self
     {
     {
         $source = $data['_source'];
         $source = $data['_source'];
-        $highlight = $data['highlight'] ?? null;
+
+        // ---------- highlight ----------
         $highlightArray = [];
         $highlightArray = [];
-        if (is_array($highlight)) {
-            foreach ($highlight as $key => $value) {
-                $highlightArray = array_merge($highlightArray, $value);
+        if (!empty($data['highlight']) && is_array($data['highlight'])) {
+            foreach ($data['highlight'] as $fragments) {
+                $highlightArray = array_merge($highlightArray, $fragments);
             }
             }
         }
         }
 
 
-        $content = $source['content'];
-        $contentArray = [];
-        if (is_array($content)) {
-            foreach ($content as $key => $value) {
-                $contentArray[] = $value;
-            }
-        }
-        $titleArray = [];
-        if (is_array($source['title'])) {
-            foreach ($source['title'] as $key => $value) {
-                $titleArray[] = $value;
-            }
-        }
-        $title = implode('', $titleArray);
+        // ---------- title ----------
+        // 取 title.text 下所有非空语言值拼接
+        $titleText = $source['title']['text'] ?? [];
+        $title = implode('', array_filter(
+            is_array($titleText) ? array_values($titleText) : [],
+            fn($v) => is_string($v) && $v !== ''
+        ));
 
 
-        $category = [];
-        if (is_array($source['category'])) {
-            $category = $source['category'];
-        } else {
-            $category = [$source['category']];
-        }
+        // ---------- content ----------
+        // 取 content.text 下所有非空语言值拼接
+        $contentText = $source['content']['text'] ?? [];
+        $content = implode('', array_filter(
+            is_array($contentText) ? array_values($contentText) : [],
+            fn($v) => is_string($v) && $v !== ''
+        ));
+
+        // ---------- display ----------
+        // 列表页查询时被 sourceExcludes 排除,值为 null
+        // 详情页通过 get() 取完整文档时才有值
+        $display = $source['content']['display'] ?? null;
+
+        // ---------- category ----------
+        $rawCategory = $source['category'] ?? [];
+        $category = is_array($rawCategory) ? $rawCategory : [$rawCategory];
 
 
         return new self(
         return new self(
             id: $source['id'],
             id: $source['id'],
-            score: $data['_score'],
+            score: $data['_score'] ?? 0,
             title: $title,
             title: $title,
-            content: implode('', $contentArray),
+            content: $content,
             path: $source['path'] ?? '',
             path: $source['path'] ?? '',
             category: $category,
             category: $category,
+            tags: $source['tags'] ?? [],
             highlight: implode('', $highlightArray),
             highlight: implode('', $highlightArray),
+            updated: $source['updated_at'],
+            type: $source['resource_type'],
+            language: $source['language'],
+            resId: $source['resource_id'],
+            display: $display,
         );
         );
     }
     }
 
 
+    /**
+     * 将 DTO 转为数组(用于 API 响应输出)
+     *
+     * display 字段仅在有值时输出,避免列表页响应体携带 null 键。
+     *
+     * @return array
+     */
+    public function toArray(): array
+    {
+        $data = [
+            'id'        => $this->id,
+            'res_id'    => $this->resId,
+            'score'     => $this->score,
+            'title'     => $this->title,
+            'content'   => $this->content,
+            'path'      => $this->path,
+            'category'  => $this->category,
+            'tags'  => $this->tags,
+            'highlight' => $this->highlight,
+            'updated'   => $this->updated,
+            'type'      => $this->type,
+            'language'  => $this->language,
+            'para_id'   => $this->getParaId(),
+            'para_link' => $this->getParaLink(),
+        ];
 
 
+        if ($this->display !== null) {
+            $data['display'] = $this->display;
+        }
+
+        return $data;
+    }
 
 
     /**
     /**
-     * 提取 para 引用ID(核心逻辑🔥)
+     * 提取 para 引用 ID
+     *
+     * 匹配文档 ID 格式 "pali_para_{bookId}_{paraId}",
+     * 返回 "{bookId}-{paraId}" 格式字符串。
+     *
+     * @return string|null
      */
      */
     public function getParaId(): ?string
     public function getParaId(): ?string
     {
     {
-        if (preg_match('/pali_para_(\d+)_(\d+)/', $this->id, $matches)) {
+        if (preg_match('/tipitaka_paragraph_pi_(\d+)-(\d+)/', $this->id, $matches)) {
             return "{$matches[1]}-{$matches[2]}";
             return "{$matches[1]}-{$matches[2]}";
         }
         }
         return null;
         return null;
     }
     }
 
 
+    /**
+     * 生成 para wiki 模板引用字符串
+     *
+     * @return string|null  例如:{{para|id=1-23|title=1-23|style=reference}}
+     */
     public function getParaLink(): ?string
     public function getParaLink(): ?string
     {
     {
         $id = $this->getParaId();
         $id = $this->getParaId();

+ 31 - 2
api-v12/app/Http/Api/TemplateRender.php

@@ -163,6 +163,9 @@ class TemplateRender
             case 'para':
             case 'para':
                 $result = $this->render_para();
                 $result = $this->render_para();
                 break;
                 break;
+            case 'category':
+                $result = $this->render_category();
+                break;
             default:
             default:
                 if (mb_substr($tpl_name, 0, 4, "UTF-8") === 'Tpl:') {
                 if (mb_substr($tpl_name, 0, 4, "UTF-8") === 'Tpl:') {
                     $result = $this->render_tpl($tpl_name);
                     $result = $this->render_tpl($tpl_name);
@@ -234,6 +237,28 @@ class TemplateRender
         return $output;
         return $output;
     }
     }
 
 
+    public function render_category()
+    {
+        $props = [];
+        $props['name'] = $this->get_param($this->param, "name", 1);
+
+        $output = [];
+        switch ($this->format) {
+            case 'react':
+                $output = [
+                    'props' => base64_encode(\json_encode($props)),
+                    'html' => $props['name'],
+                    'tag' => 'span',
+                    'tpl' => 'para',
+                ];
+                break;
+            default:
+                $output = $props['name'];
+                break;
+        }
+        return $output;
+    }
+
     public function getTermProps($word, $tag = null, $channel = null)
     public function getTermProps($word, $tag = null, $channel = null)
     {
     {
         if ($channel && !empty($channel)) {
         if ($channel && !empty($channel)) {
@@ -979,15 +1004,19 @@ class TemplateRender
             true,
             true,
             $this->format
             $this->format
         );
         );
-        if ($props === false) {
+        if (!$props) {
             $props['error'] = "句子模版渲染错误。句子参数个数不符。应该是四个。";
             $props['error'] = "句子模版渲染错误。句子参数个数不符。应该是四个。";
+            Log::error('句子模版渲染错误。句子参数个数不符。应该是四个。');
         }
         }
         if ($this->mode === 'read') {
         if ($this->mode === 'read') {
             $tpl = "sentread";
             $tpl = "sentread";
         } else {
         } else {
             $tpl = "sentedit";
             $tpl = "sentedit";
         }
         }
-        $props['show'] = $show;
+        if (is_array($props)) {
+            $props['show'] = $show;
+        }
+
         //输出引用
         //输出引用
         $arrSid = explode('-', $sid);
         $arrSid = explode('-', $sid);
         $bookPara = array_slice($arrSid, 0, 2);
         $bookPara = array_slice($arrSid, 0, 2);

+ 29 - 5
api-v12/app/Http/Controllers/CorpusController.php

@@ -109,8 +109,13 @@ class CorpusController extends Controller
     {
     {
         //
         //
     }
     }
-    public function getSentTpl($id, $inputChannels, $mode = 'edit', $onlyProps = false, $format = 'react')
-    {
+    public function getSentTpl(
+        $id,
+        $inputChannels,
+        $mode = 'edit',
+        $onlyProps = false,
+        $format = 'react'
+    ) {
         $sent = [];
         $sent = [];
         $channels = $inputChannels;
         $channels = $inputChannels;
         $sentId = \explode('-', $id);
         $sentId = \explode('-', $id);
@@ -157,7 +162,18 @@ class CorpusController extends Controller
                 break;
                 break;
             }
             }
         }
         }
-        return $this->makeContent($record, $mode, $channelIndex, [], $onlyProps, false, $format);
+        if (count($record) === 0) {
+            return null;
+        }
+        return $this->makeContent(
+            $record,
+            $mode,
+            $channelIndex,
+            [],
+            $onlyProps,
+            false,
+            $format
+        );
     }
     }
     /**
     /**
      * Display the specified resource.
      * Display the specified resource.
@@ -576,8 +592,15 @@ class CorpusController extends Controller
      * $indexChannel channel索引
      * $indexChannel channel索引
      * $indexedHeading 标题索引 用于给段落加标题标签 <h1> ect.
      * $indexedHeading 标题索引 用于给段落加标题标签 <h1> ect.
      */
      */
-    private function makeContent($record, $mode, $indexChannel, $indexedHeading = [], $onlyProps = false, $paraMark = false, $format = 'react')
-    {
+    private function makeContent(
+        $record,
+        $mode,
+        $indexChannel,
+        $indexedHeading = [],
+        $onlyProps = false,
+        $paraMark = false,
+        $format = 'react'
+    ) {
         $content = [];
         $content = [];
         $sent = [];
         $sent = [];
         $sent["origin"] = [];
         $sent["origin"] = [];
@@ -797,6 +820,7 @@ class CorpusController extends Controller
                         break;
                         break;
                 }
                 }
             }
             }
+            //FIXME 移到for外面
             if ($onlyProps) {
             if ($onlyProps) {
                 return $sent;
                 return $sent;
             }
             }

+ 100 - 131
api-v12/app/Http/Controllers/Library/BookController.php

@@ -4,6 +4,9 @@ namespace App\Http\Controllers\Library;
 
 
 use App\Http\Controllers\Controller;
 use App\Http\Controllers\Controller;
 
 
+use Illuminate\Support\Facades\Log;
+
+
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use App\Models\ProgressChapter;
 use App\Models\ProgressChapter;
 use App\Models\PaliText;
 use App\Models\PaliText;
@@ -11,11 +14,23 @@ use App\Models\Sentence;
 
 
 use App\Services\ChapterService;
 use App\Services\ChapterService;
 use App\Services\PaliContentService;
 use App\Services\PaliContentService;
+use App\Services\OpenSearchService;
+
+use App\DTO\Search\HitItemDTO;
+
 
 
 class BookController extends Controller
 class BookController extends Controller
 {
 {
     protected $maxChapterLen = 50000;
     protected $maxChapterLen = 50000;
     protected $minChapterLen = 100;
     protected $minChapterLen = 100;
+
+    /**
+     * 构造函数,注入 OpenSearchService
+     *
+     * @param  \App\Services\OpenSearchService  $searchService
+     */
+    public function __construct(protected OpenSearchService $searchService) {}
+
     public function show($id)
     public function show($id)
     {
     {
         $bookRaw = $this->loadBook($id);
         $bookRaw = $this->loadBook($id);
@@ -51,27 +66,49 @@ class BookController extends Controller
 
 
     public function read(Request $request, $id)
     public function read(Request $request, $id)
     {
     {
-        $chapterService = app(ChapterService::class);
-        [$book, $para] = explode('-', $id);
+        $start = microtime(true);
+        $lap = function (string $label) use ($start): void {
+            $elapsed = round((microtime(true) - $start) * 1000, 2);
+            Log::debug("[book.read] {$label}", ['elapsed_ms' => $elapsed]);
+        };
+
+        $lap('start');
+
         $channelId = $request->input('channel');
         $channelId = $request->input('channel');
-        $chapterUid = $chapterService->getUidByChannel($book, $para, $channelId);
-        $bookRaw = $this->loadBook($chapterUid);
+        $openSearchId = "tipitaka_chapter_{$id}_{$channelId}";
 
 
-        if (!$bookRaw) {
-            abort(404);
-        }
-        $channelId = $bookRaw->channel_id; // 替换为具体的 channel_id 值
+        $chapter = HitItemDTO::fromArray($this->searchService->get($openSearchId))->toArray();
+        $lap('searchService->get + HitItemDTO');
 
 
-        $book = $this->getBookInfo($bookRaw);
-        $book['toc'] = $this->getBookToc($bookRaw->book, $bookRaw->para, $channelId, 2, 7);
-        $book['categories'] = $this->getBookCategory($bookRaw->book, $bookRaw->para);
-        $book['tags'] = [];
-        $book['pagination'] = $this->pagination($bookRaw);
-        $book['content'] = $this->getBookContent($chapterUid);
-        $channels = $chapterService->publicChannels($bookRaw->book, $bookRaw->para);
-        $editor_link = config('mint.server.dashboard_base_path') . "/workspace/tipitaka/chapter/{$id}?channel={$channelId}";
-
-        return view('library.book.read', compact('book', 'channels', 'editor_link'));
+        [$bookId, $paraId] = explode('-', $id);
+
+        $chapterService = app(ChapterService::class);
+
+        $book = [];
+
+        $book['toc'] = $this->getBookToc((int)$bookId, (int)$paraId, $channelId, 2, 7);
+        $lap('getBookToc');
+
+        $book['categories'] = $chapter['category'];
+        $book['title']      = $chapter['title'];
+        $book['author']     = 'author'; // FIXME
+        $book['tags']       = [];
+
+        $book['pagination'] = $this->pagination((int)$bookId, (int)$paraId, $channelId);
+        $lap('pagination');
+
+        $book['content'] = $chapter['display'];
+
+        $channels = $chapterService->publicChannels((int)$bookId, (int)$paraId);
+        $lap('publicChannels');
+
+        $editor_link = config('mint.server.dashboard_base_path')
+            . "/workspace/tipitaka/chapter/{$id}?channel={$channelId}";
+
+        $view = view('library.book.read', compact('book', 'channels', 'editor_link'));
+        $lap('view compiled — total');
+
+        return $view;
     }
     }
 
 
     private function loadBook($id)
     private function loadBook($id)
@@ -107,50 +144,53 @@ class BookController extends Controller
         ];
         ];
     }
     }
 
 
-    private function getBookToc(int $book, int $paragraph, string $channelId, $minLevel = 2, $maxLevel = 2)
+    private function getBookToc(int $book, int $paragraph, string $channelId, $minLevel = 2, $maxLevel = 2): array
     {
     {
-        //先找到书的起始(书名)章节
-        //一个book 里面可以有多本书
         $currBook = $this->bookStart($book, $paragraph);
         $currBook = $this->bookStart($book, $paragraph);
+
         $start = $currBook->paragraph;
         $start = $currBook->paragraph;
-        $end = $currBook->paragraph + $currBook->chapter_len - 1;
+        $end   = $currBook->paragraph + $currBook->chapter_len - 1;
+
         $paliTexts = PaliText::where('book', $book)
         $paliTexts = PaliText::where('book', $book)
-            ->whereBetween('paragraph',  [$start, $end])
-            ->whereBetween('level', [$minLevel, $maxLevel])->orderBy('paragraph')->get();
+            ->whereBetween('paragraph', [$start, $end])
+            ->whereBetween('level', [$minLevel, $maxLevel])
+            ->orderBy('paragraph')
+            ->get();
 
 
         $chapters = ProgressChapter::where('book', $book)
         $chapters = ProgressChapter::where('book', $book)
             ->whereBetween('para', [$start, $end])
             ->whereBetween('para', [$start, $end])
-            ->where('channel_id', $channelId)->orderBy('para')->get();
-
-        $toc = $paliTexts->map(function ($paliText) use ($chapters, $channelId) {
-            $title = $paliText->toc;
-            if (count($chapters) > 0) {
-                $found = array_filter($chapters->toArray(), function ($chapter) use ($paliText) {
-                    return $chapter['book'] == $paliText->book && $chapter['para'] == $paliText->paragraph;
-                });
-                if (count($found) > 0) {
-                    $chapter = array_shift($found);
-                    if (!empty($chapter['title'])) {
-                        $title = $chapter['title'];
-                    }
-                    if (!empty($chapter['summary'])) {
-                        $summary = $chapter['summary'];
-                    }
-                    $progress = (int)($chapter['progress'] * 100);
-                    $id = $chapter['uid'];
-                }
+            ->where('channel_id', $channelId)
+            ->orderBy('para')
+            ->get();
+
+        // keyBy 建索引,map 里 O(1) 查找,完全避免 toArray() 序列化和 array_filter O(n×m) 扫描
+        $chaptersIndexed = $chapters->keyBy('para');
+
+        return $paliTexts->map(function ($paliText) use ($chaptersIndexed, $channelId) {
+            $title    = $paliText->toc;
+            $summary  = '';
+            $progress = 0;
+            $disabled = true;
+
+            /** @var ProgressChapter|null $chapter */
+            $chapter = $chaptersIndexed->get($paliText->paragraph);
+            if ($chapter) {
+                if (!empty($chapter->title))   $title   = $chapter->title;
+                if (!empty($chapter->summary)) $summary = $chapter->summary;
+                $progress = (int)($chapter->progress * 100);
+                $disabled = false;
             }
             }
+
             return [
             return [
-                "id" => "{$paliText->book}-{$paliText->paragraph}",
-                "channel" => $channelId,
-                "title" => $title,
-                "summary" => $summary ?? "",
-                "progress" => $progress ?? 0,
-                "level" => (int)$paliText->level,
-                "disabled" => !isset($progress),
+                'id'       => "{$paliText->book}-{$paliText->paragraph}",
+                'channel'  => $channelId,
+                'title'    => $title,
+                'summary'  => $summary,
+                'progress' => $progress,
+                'level'    => (int)$paliText->level,
+                'disabled' => $disabled,
             ];
             ];
-        })->toArray();
-        return $toc;
+        })->all();
     }
     }
 
 
     public function getBookCategory($book, $paragraph)
     public function getBookCategory($book, $paragraph)
@@ -173,91 +213,20 @@ class BookController extends Controller
             ->first();
             ->first();
         return $currBook;
         return $currBook;
     }
     }
-    public function getBookContent($id)
-    {
-        //查询book信息
-        $book = $this->loadBook($id);
-        $currBook = $this->bookStart($book->book, $book->para);
-        $start = $currBook->paragraph;
-        $end = $currBook->paragraph + $currBook->chapter_len - 1;
-        // 查询起始段落
-        $paragraphs = PaliText::where('book', $book->book)
-            ->whereBetween('paragraph', [$start, $end])
-            ->where('level', '<', 8)
-            ->orderBy('paragraph')
-            ->get();
-        $curr = $paragraphs->firstWhere('paragraph', $book->para);
-        $endParagraph = $curr->paragraph + $curr->chapter_len - 1;
-        if ($curr->chapter_strlen > $this->maxChapterLen) {
-            //太大了,修改结束位置 找到下一级
-            foreach ($paragraphs as $key => $paragraph) {
-                if ($paragraph->paragraph > $curr->paragraph) {
-                    if ($paragraph->chapter_strlen <= $this->maxChapterLen) {
-                        $endParagraph = $paragraph->paragraph + $paragraph->chapter_len - 1;
-                        break;
-                    }
-                    if ($paragraph->level <= $curr->level) {
-                        //不能往下走了,就是它了
-                        $endParagraph = $paragraphs[$key - 1]->paragraph + $paragraphs[$key - 1]->chapter_len - 1;
-                        break;
-                    }
-                }
-            }
-        }
 
 
-        $paraStart = $curr->paragraph;
-        $paraEnd = $endParagraph;
-        $paragraphs = app(PaliContentService::class)->paragraphs(
-            $book->book,
-            $paraStart,
-            $paraEnd,
-            [$book->channel_id],
-            ['mode' => 'read', 'format' => 'html', 'original' => false]
-        );
-
-        //获取句子数据
-
-        $pali = PaliText::where('book', $book->book)
-            ->whereBetween('paragraph', [$curr->paragraph, $endParagraph])
-            ->select(['paragraph', 'level'])
-            ->orderBy('paragraph')
-            ->get();
-        $result = [];
-        foreach ($paragraphs as $key => $paragraph) {
-            $content = [];
-            foreach ($paragraph['children'] as $key => $sent) {
-                if (isset($sent['translation'])) {
-                    foreach ($sent['translation'] as $key => $translation) {
-                        $curr = $translation['html'] ?? $translation['content'];
-                        $content[] = "<span class='sentence'>{$curr}</span>";
-                    }
-                }
-            }
-            $currPaliPara = $pali->firstWhere('paragraph', $paragraph['para']);
-            $level = $currPaliPara->level;
-            $paragraph = [
-                'id' => $paragraph['para'],
-                'level' => $level,
-                'text' => [[implode('', $content)]],
-            ];
-            $result[] = $paragraph;
-        }
-
-        return $result;
-    }
 
 
-    public function pagination($book)
+    public function pagination(int $book, int $para, string $channelId)
     {
     {
-        $currBook = $this->bookStart($book->book, $book->para);
+        $currBook = $this->bookStart($book, $para);
         $start = $currBook->paragraph;
         $start = $currBook->paragraph;
         $end = $currBook->paragraph + $currBook->chapter_len - 1;
         $end = $currBook->paragraph + $currBook->chapter_len - 1;
         // 查询起始段落
         // 查询起始段落
-        $paragraphs = PaliText::where('book', $book->book)
+        $paragraphs = PaliText::where('book', $book)
             ->whereBetween('paragraph', [$start, $end])
             ->whereBetween('paragraph', [$start, $end])
             ->where('level', '<', 8)
             ->where('level', '<', 8)
             ->orderBy('paragraph')
             ->orderBy('paragraph')
             ->get();
             ->get();
-        $curr = $paragraphs->firstWhere('paragraph', $book->para);
+        $curr = $paragraphs->firstWhere('paragraph', $para);
         $current = $curr; //实际显示的段落
         $current = $curr; //实际显示的段落
         $endParagraph = $curr->paragraph + $curr->chapter_len - 1;
         $endParagraph = $curr->paragraph + $curr->chapter_len - 1;
         if ($curr->chapter_strlen > $this->maxChapterLen) {
         if ($curr->chapter_strlen > $this->maxChapterLen) {
@@ -288,7 +257,7 @@ class BookController extends Controller
             $nextTranslation = ProgressChapter::with('channel.owner')
             $nextTranslation = ProgressChapter::with('channel.owner')
                 ->where('book', $nextPali->book)
                 ->where('book', $nextPali->book)
                 ->where('para', $nextPali->paragraph)
                 ->where('para', $nextPali->paragraph)
-                ->where('channel_id', $book->channel_id)
+                ->where('channel_id', $channelId)
                 ->first();
                 ->first();
             if ($nextTranslation) {
             if ($nextTranslation) {
                 if (!empty($nextTranslation->title)) {
                 if (!empty($nextTranslation->title)) {
@@ -296,7 +265,7 @@ class BookController extends Controller
                 } else {
                 } else {
                     $next['title'] = $nextPali->toc;
                     $next['title'] = $nextPali->toc;
                 }
                 }
-                $next['id'] = $nextTranslation->uid;
+                $next['id'] = "{$nextPali->book}-{$nextPali->paragraph}";
             }
             }
         }
         }
 
 
@@ -305,7 +274,7 @@ class BookController extends Controller
             $prevTranslation = ProgressChapter::with('channel.owner')
             $prevTranslation = ProgressChapter::with('channel.owner')
                 ->where('book', $prevPali->book)
                 ->where('book', $prevPali->book)
                 ->where('para', $prevPali->paragraph)
                 ->where('para', $prevPali->paragraph)
-                ->where('channel_id', $book->channel_id)
+                ->where('channel_id', $channelId)
                 ->first();
                 ->first();
             if ($prevTranslation) {
             if ($prevTranslation) {
                 if (!empty($prevTranslation->title)) {
                 if (!empty($prevTranslation->title)) {
@@ -313,7 +282,7 @@ class BookController extends Controller
                 } else {
                 } else {
                     $prev['title'] = $prevPali->toc;
                     $prev['title'] = $prevPali->toc;
                 }
                 }
-                $prev['id'] = $prevTranslation->uid;
+                $prev['id'] = "{$prevPali->book}-{$prevPali->paragraph}";
             }
             }
         }
         }
 
 

+ 230 - 0
api-v12/app/Http/Controllers/Library/HomeController.php

@@ -0,0 +1,230 @@
+<?php
+
+namespace App\Http\Controllers\Library;
+
+use App\Http\Controllers\Controller;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\File;
+
+
+use App\Models\PaliText;
+use App\Models\ProgressChapter;
+use App\Models\Tag;
+use App\Models\TagMap;
+
+
+class HomeController extends Controller
+{
+    // 封面渐变色池:uid 首字节取余循环,保证同一文集颜色稳定
+    private array $coverGradients = [
+        'linear-gradient(160deg, #2d1020, #ae6b8b)',
+        'linear-gradient(160deg, #1a2d10,rgba(75, 114, 36, 0.61))',
+        'linear-gradient(160deg, #0d1f3c,rgb(55, 98, 150))',
+        'linear-gradient(160deg, #2d1020,rgb(151, 69, 94))',
+        'linear-gradient(160deg, #1a1a2d,rgb(76, 68, 146))',
+        'linear-gradient(160deg, #1a2820,rgb(55, 124, 99))',
+    ];
+    // -------------------------------------------------------------------------
+    // 从 uid / id 字符串中提取一个稳定的整数,用于色池取余
+    // -------------------------------------------------------------------------
+    private function colorIndex(string $uid): int
+    {
+        return hexdec(substr(str_replace('-', '', $uid), 0, 4)) % 255;
+    }
+    protected static int $nextId = 1;
+
+    public function index()
+    {
+        $categories = $this->loadCategories();
+
+        // 获取一级分类和对应的书籍
+        $categoryData = [];
+        foreach ($categories as $category) {
+            if ($category['level'] == 1) {
+                $children = $this->subCategories($categories, $category['id']);
+                $categoryData[] = [
+                    'category' => $category,
+                    'children' => $children,
+                ];
+            }
+        }
+        $recentBooks = $this->getUpdateBooks();
+
+        return view('library.index', compact(
+            'categoryData',
+            'categories',
+            'recentBooks'
+        ));
+    }
+
+
+
+    private function subCategories($categories, int $id)
+    {
+        return array_filter($categories, function ($cat) use ($id) {
+            return $cat['parent_id'] == $id;
+        });
+    }
+    private function getRecent()
+    {
+        return [
+            [
+                'id'         => 'book-001',
+                'title'      => '相应部·因缘篇',
+                'author'     => 'Bhikkhu Bodhi',
+                'cover'      => null,                          // 无封面时显示渐变
+                'cover_gradient' => 'linear-gradient(135deg, #2d5a8e 0%, #1a3a5c 100%)',
+                'updated_at' => '2小时前',
+                'is_new'     => true,                          // true=新增, false=更新
+                'category'   => '经藏',
+            ],
+            [
+                'id'         => 'book-002',
+                'title'      => '长部·梵网经',
+                'author'     => 'Bhikkhu Sujato',
+                'cover'      => null,
+                'cover_gradient' => 'linear-gradient(135deg, #5a2d8e 0%, #3a1a5c 100%)',
+                'updated_at' => '昨天',
+                'is_new'     => false,
+                'category'   => '经藏',
+            ],
+            [
+                'id'         => 'book-003',
+                'title'      => '法句经注',
+                'author'     => 'Buddhaghosa',
+                'cover'      => null,
+                'cover_gradient' => 'linear-gradient(135deg, #8e5a2d 0%, #5c3a1a 100%)',
+                'updated_at' => '3天前',
+                'is_new'     => false,
+                'category'   => '注释',
+            ],
+            [
+                'id'         => 'book-004',
+                'title'      => '律藏·波罗夷',
+                'author'     => 'Bhikkhu Brahmali',
+                'cover'      => null,
+                'cover_gradient' => 'linear-gradient(135deg, #2d8e5a 0%, #1a5c3a 100%)',
+                'updated_at' => '5天前',
+                'is_new'     => true,
+                'category'   => '律藏',
+            ],
+            [
+                'id'         => 'book-005',
+                'title'      => '清净道论',
+                'author'     => 'Buddhaghosa',
+                'cover'      => null,
+                'cover_gradient' => 'linear-gradient(135deg, #8e2d2d 0%, #5c1a1a 100%)',
+                'updated_at' => '1周前',
+                'is_new'     => false,
+                'category'   => '注释',
+            ],
+            [
+                'id'         => 'book-006',
+                'title'      => '增支部·一集',
+                'author'     => 'Bhikkhu Bodhi',
+                'cover'      => null,
+                'cover_gradient' => 'linear-gradient(135deg, #2d7a8e 0%, #1a4a5c 100%)',
+                'updated_at' => '1周前',
+                'is_new'     => false,
+                'category'   => '经藏',
+            ],
+        ];
+    }
+
+    private function getUpdateBooks()
+    {
+        $books = ProgressChapter::with('channel.owner')
+            ->leftJoin('pali_texts', function ($join) {
+                $join->on('progress_chapters.book', '=', 'pali_texts.book')
+                    ->on('progress_chapters.para', '=', 'pali_texts.paragraph');
+            })
+            ->whereHas('channel', function ($query) {
+                $query->where('status', 30);
+            })
+            ->where('progress', '>', config('mint.library.list_min_progress'))
+            ->take(10)
+            ->get();
+
+        return $this->getBooksInfo($books);
+    }
+
+
+    private function getBooksInfo($books)
+    {
+        $pali = PaliText::where('level', 1)->get();
+        // 获取该分类下的书籍
+        $categoryBooks = [];
+        $books->each(function ($book) use (&$categoryBooks,  $pali) {
+            $title = $book->title;
+            if (empty($title)) {
+                $title = $pali->firstWhere('book', $book->book)->toc;
+            }
+            //Log::debug('getBooksInfo', ['book' => $book->book, 'paragraph' => $book->para]);
+            $pcd_book_id = $pali->first(function ($item) use ($book) {
+                return $item->book == $book->book
+                    && $item->paragraph == $book->para;
+            })?->pcd_book_id;
+
+            $coverFile = "/assets/images/cover/zh-hans/1/{$pcd_book_id}.png";
+            if (File::exists(public_path($coverFile))) {
+                $coverUrl = $coverFile;
+            } else {
+                $coverUrl = null;
+            }
+            $colorIdx = $this->colorIndex($book->uid);
+
+            $categoryBooks[] = [
+                "id" => $book->uid,
+                "title" => $title,
+                "author" => $book->channel->name,
+                "publisher" => $book->channel->owner,
+                "type" => __('labels.' . $book->channel->type),
+                "cover" => $coverUrl,
+                'cover_gradient' => $this->coverGradients[$colorIdx % count($this->coverGradients)],
+                "description" => $book->summary ?? "比库戒律的详细说明",
+                "language" => __('language.' . $book->channel->lang),
+
+                'updated_at' => $book->updated_at,
+                'is_new'     => false, //FIXME
+                'category'   => '经藏', //FIXME
+            ];
+        });
+        return $categoryBooks;
+    }
+    private function loadCategories()
+    {
+        $json = file_get_contents(public_path("data/category/default.json"));
+        $tree = json_decode($json, true);
+        $flat = self::flattenWithIds($tree);
+        return $flat;
+    }
+
+    public static function flattenWithIds(array $tree,  int $parentId = 0, int $level = 1): array
+    {
+
+        $flat = [];
+
+        foreach ($tree as $node) {
+            $currentId = self::$nextId++;
+
+            $item = [
+                'id' => $currentId,
+                'parent_id' => $parentId,
+                'name' => $node['name'] ?? null,
+                'tag' => $node['tag'] ?? [],
+                "description" => "佛教戒律经典",
+                'level' => $level,
+            ];
+
+            $flat[] = $item;
+
+            if (isset($node['children']) && is_array($node['children'])) {
+                $childrenLevel = $level + 1;
+                $flat = array_merge($flat, self::flattenWithIds($node['children'],  $currentId, $childrenLevel));
+            }
+        }
+
+        return $flat;
+    }
+}

+ 22 - 18
api-v12/app/Http/Controllers/Library/SearchController.php

@@ -7,7 +7,7 @@ use App\Http\Controllers\Controller;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use App\Services\OpenSearchService;
 use App\Services\OpenSearchService;
 use App\DTO\Search\SearchDataDTO;
 use App\DTO\Search\SearchDataDTO;
-
+use Carbon\Carbon;
 
 
 class SearchController extends Controller
 class SearchController extends Controller
 {
 {
@@ -17,7 +17,7 @@ class SearchController extends Controller
         $category = $request->input('category', 'all');
         $category = $request->input('category', 'all');
         $page     = max(1, (int) $request->input('page', 1));
         $page     = max(1, (int) $request->input('page', 1));
         $perPage  = 10;
         $perPage  = 10;
-        $lang = $request->input('lang');
+        $lang = $request->input('language');
 
 
         $search = app(OpenSearchService::class);
         $search = app(OpenSearchService::class);
         // 组装搜索参数
         // 组装搜索参数
@@ -26,26 +26,32 @@ class SearchController extends Controller
             'pageSize'     => $perPage,
             'pageSize'     => $perPage,
             'page'     => $page,
             'page'     => $page,
             'language'     => $lang,
             'language'     => $lang,
-            'resourceType'     => $request->input('type'),
+            'resourceType'     => $request->input('resource_type'),
         ];
         ];
         $result = $search->search($params);
         $result = $search->search($params);
 
 
         $dto = SearchDataDTO::fromArray($result);
         $dto = SearchDataDTO::fromArray($result);
         $results = [];
         $results = [];
         foreach ($dto->hits->items as $key => $item) {
         foreach ($dto->hits->items as $key => $item) {
-            $results[] = [
-                'word'     => $item->id,
-                'zh'       => $item->title,
-                'lang'     => 'pi',
-                'category' => '法义术语',
+            $data = [
+                'id'     => $item->resId,
+                'title'       => $item->title,
+                'type'     => $item->type,
+                'lang'     => $item->language,
+                'category' => $item->type,
                 'quality'  => 'featured',
                 'quality'  => 'featured',
                 'snippet'  => !empty($item->highlight) ? $item->highlight : $item->content,
                 'snippet'  => !empty($item->highlight) ? $item->highlight : $item->content,
-                'updated'  => '2025-11-12',
+                'updated'  => Carbon::parse($item->updated)->format('Y-m-d'),
             ];
             ];
+            if ($item->type === 'tipitaka') {
+                $arrId = explode('_', $item->id);
+                $data['chapter'] = ['id' => $arrId['2'], 'channel' => $arrId['3']];
+            }
+            $results[] = $data;
         }
         }
 
 
-        $category = $dto->aggregations->category->buckets;
-
+        $aggregations = $dto->aggregations->toArray();
+        unset($aggregations['resource_type']);
         // 分页对象(兼容 Blade paginator 风格)
         // 分页对象(兼容 Blade paginator 风格)
         $pagination = [
         $pagination = [
             'total'        => $dto->hits->total,
             'total'        => $dto->hits->total,
@@ -60,8 +66,8 @@ class SearchController extends Controller
             'results'        => $results,
             'results'        => $results,
             'pagination'     => $pagination,
             'pagination'     => $pagination,
             'category'       => 'all',
             'category'       => 'all',
-            'filters'     => $category,
-            'categories'     => $this->types(),
+            'filters'     => $aggregations,
+            'types'     => $this->types(),
             'recentUpdates'  => [],
             'recentUpdates'  => [],
         ]);
         ]);
     }
     }
@@ -70,11 +76,9 @@ class SearchController extends Controller
     private function types(): array
     private function types(): array
     {
     {
         return [
         return [
-            ['slug' => 'all',      'label' => '全部'],
-            ['slug' => 'term',     'label' => '法义术语'],
-            ['slug' => 'original_text',   'label' => '原文'],
-            ['slug' => 'translation',     'label' => '译文'],
-            ['slug' => 'article',   'label' => '文章'],
+            ['slug' => 'term',     'label' => '百科词条'],
+            ['slug' => 'tipitaka',   'label' => '巴利三藏'],
+            ['slug' => 'article',   'label' => '文章文集'],
             ['slug' => 'course', 'label' => '课程'],
             ['slug' => 'course', 'label' => '课程'],
             ['slug' => 'dictionary',    'label' => '字典'],
             ['slug' => 'dictionary',    'label' => '字典'],
         ];
         ];

+ 31 - 175
api-v12/app/Http/Controllers/CategoryController.php → api-v12/app/Http/Controllers/Library/TipitakaController.php

@@ -1,8 +1,11 @@
 <?php
 <?php
 
 
-namespace App\Http\Controllers;
+namespace App\Http\Controllers\Library;
+
+use App\Http\Controllers\Controller;
 
 
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
+
 use Illuminate\Support\Facades\File;
 use Illuminate\Support\Facades\File;
 
 
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
@@ -12,7 +15,7 @@ use App\Models\Tag;
 use App\Models\TagMap;
 use App\Models\TagMap;
 
 
 
 
-class CategoryController extends Controller
+class TipitakaController extends Controller
 {
 {
     // 封面渐变色池:uid 首字节取余循环,保证同一文集颜色稳定
     // 封面渐变色池:uid 首字节取余循环,保证同一文集颜色稳定
     private array $coverGradients = [
     private array $coverGradients = [
@@ -31,49 +34,7 @@ class CategoryController extends Controller
         return hexdec(substr(str_replace('-', '', $uid), 0, 4)) % 255;
         return hexdec(substr(str_replace('-', '', $uid), 0, 4)) % 255;
     }
     }
     protected static int $nextId = 1;
     protected static int $nextId = 1;
-    public function home()
-    {
-        $categories = $this->loadCategories();
-
-        // 获取一级分类和对应的书籍
-        $categoryData = [];
-        foreach ($categories as $category) {
-            if ($category['level'] == 1) {
-                $children = $this->subCategories($categories, $category['id']);
-                $categoryData[] = [
-                    'category' => $category,
-                    'children' => $children,
-                ];
-            }
-        }
-        $recentBooks = $this->getRecent();
-
-        return view('library.index', compact(
-            'categoryData',
-            'categories',
-            'recentBooks'
-        ));
-    }
-
-
-    public function index()
-    {
-        $categories = $this->loadCategories();
 
 
-        // 获取一级分类和对应的书籍
-        $categoryData = [];
-        foreach ($categories as $category) {
-            if ($category['level'] == 1) {
-                $children = $this->subCategories($categories, $category['id']);
-                $categoryData[] = [
-                    'category' => $category,
-                    'children' => $children,
-                ];
-            }
-        }
-
-        return view('library.index', compact('categoryData', 'categories'));
-    }
 
 
     // app/Http/Controllers/Library/CategoryController.php
     // app/Http/Controllers/Library/CategoryController.php
     // category() 方法修改版
     // category() 方法修改版
@@ -84,7 +45,7 @@ class CategoryController extends Controller
     //   4. 新增 $filterOptions(过滤器选项 + 计数)
     //   4. 新增 $filterOptions(过滤器选项 + 计数)
     //   5. 新增 $totalCount
     //   5. 新增 $totalCount
 
 
-    public function category(?int $id = null)
+    public function index(Request $request, ?int $id = null)
     {
     {
 
 
         $categories = $this->loadCategories();
         $categories = $this->loadCategories();
@@ -233,88 +194,7 @@ class CategoryController extends Controller
             return $cat['parent_id'] == $id;
             return $cat['parent_id'] == $id;
         });
         });
     }
     }
-    private function getRecent()
-    {
-        return [
-            [
-                'id'         => 'book-001',
-                'title'      => '相应部·因缘篇',
-                'author'     => 'Bhikkhu Bodhi',
-                'cover'      => null,                          // 无封面时显示渐变
-                'cover_gradient' => 'linear-gradient(135deg, #2d5a8e 0%, #1a3a5c 100%)',
-                'updated_at' => '2小时前',
-                'is_new'     => true,                          // true=新增, false=更新
-                'category'   => '经藏',
-            ],
-            [
-                'id'         => 'book-002',
-                'title'      => '长部·梵网经',
-                'author'     => 'Bhikkhu Sujato',
-                'cover'      => null,
-                'cover_gradient' => 'linear-gradient(135deg, #5a2d8e 0%, #3a1a5c 100%)',
-                'updated_at' => '昨天',
-                'is_new'     => false,
-                'category'   => '经藏',
-            ],
-            [
-                'id'         => 'book-003',
-                'title'      => '法句经注',
-                'author'     => 'Buddhaghosa',
-                'cover'      => null,
-                'cover_gradient' => 'linear-gradient(135deg, #8e5a2d 0%, #5c3a1a 100%)',
-                'updated_at' => '3天前',
-                'is_new'     => false,
-                'category'   => '注释',
-            ],
-            [
-                'id'         => 'book-004',
-                'title'      => '律藏·波罗夷',
-                'author'     => 'Bhikkhu Brahmali',
-                'cover'      => null,
-                'cover_gradient' => 'linear-gradient(135deg, #2d8e5a 0%, #1a5c3a 100%)',
-                'updated_at' => '5天前',
-                'is_new'     => true,
-                'category'   => '律藏',
-            ],
-            [
-                'id'         => 'book-005',
-                'title'      => '清净道论',
-                'author'     => 'Buddhaghosa',
-                'cover'      => null,
-                'cover_gradient' => 'linear-gradient(135deg, #8e2d2d 0%, #5c1a1a 100%)',
-                'updated_at' => '1周前',
-                'is_new'     => false,
-                'category'   => '注释',
-            ],
-            [
-                'id'         => 'book-006',
-                'title'      => '增支部·一集',
-                'author'     => 'Bhikkhu Bodhi',
-                'cover'      => null,
-                'cover_gradient' => 'linear-gradient(135deg, #2d7a8e 0%, #1a4a5c 100%)',
-                'updated_at' => '1周前',
-                'is_new'     => false,
-                'category'   => '经藏',
-            ],
-        ];
-    }
-
-    private function getUpdateBooks()
-    {
-        $books = ProgressChapter::with('channel.owner')
-            ->leftJoin('pali_texts', function ($join) {
-                $join->on('progress_chapters.book', '=', 'pali_texts.book')
-                    ->on('progress_chapters.para', '=', 'pali_texts.paragraph');
-            })
-            ->whereHas('channel', function ($query) {
-                $query->where('status', 30);
-            })
-            ->where('progress', '>', config('mint.library.list_min_progress'))
-            ->take(10)
-            ->get();
 
 
-        return $this->getBooksInfo($books);
-    }
     private function getBooks($categories, $id, $filters)
     private function getBooks($categories, $id, $filters)
     {
     {
 
 
@@ -325,58 +205,34 @@ class CategoryController extends Controller
             }
             }
             // 标签查章节
             // 标签查章节
             $tagNames = $currentCategory['tag'];
             $tagNames = $currentCategory['tag'];
-            $tm = (new TagMap)->getTable();
-            $tg = (new Tag)->getTable();
-            $pt = (new PaliText)->getTable();
-            $where1 = " where co = " . count($tagNames);
-            $a = implode(",", array_fill(0, count($tagNames), '?'));
-            $in1 = "and t.name in ({$a})";
-            $param = $tagNames;
-            $where2 = "where level = 1";
-            $query = "select uid as id,book,paragraph,level,toc as title,chapter_strlen,parent,path from (
-                            select anchor_id as cid from (
-                                select tm.anchor_id , count(*) as co
-                                    from $tm as  tm
-                                    left join $tg as t on tm.tag_id = t.id
-                                    where tm.table_name  = 'pali_texts'
-                                    $in1
-                                    group by tm.anchor_id
-                            ) T
-                                $where1
-                        ) CID
-                        left join $pt as pt on CID.cid = pt.uid
-                        $where2
-                        order by book,paragraph";
-
-            $chapters = DB::select($query, $param);
-            $chaptersParam = [];
-            foreach ($chapters as $key => $chapter) {
-                $chaptersParam[] = [$chapter->book, $chapter->paragraph];
-            }
-            // 获取该分类下的章节
-            $books = ProgressChapter::with('channel.owner')
-                ->whereIns(['progress_chapters.book', 'progress_chapters.para'], $chaptersParam)
-                ->whereHas('channel', function ($query) {
-                    $query->where('status', 30);
-                })
-                ->where('progress', '>', config('mint.library.list_min_progress'))
-                ->get();
+            $booksChapter = PaliText::withAllTags($tagNames)
+                ->where('level', 1)->get();
         } else {
         } else {
-            $booksChapter = PaliText::select(['book', 'paragraph'])->where('level', 1)->get();
-            $chapters = [];
-            foreach ($booksChapter as $key => $value) {
-                $chapters[] = [$value->book, $value->paragraph];
-            }
-            $books = ProgressChapter::with('channel.owner')
-                ->whereHas('channel', function ($query) use ($filters) {
-                    $filters['type'] === 'all' ? $query->where('status', 30) :
-                        $query->where('status', 30)->where('type', $filters['type']);
-                })
-                ->where('progress', '>', config('mint.library.list_min_progress'))
-                ->whereIns(['book', 'para'], $chapters)
-                ->take(100)
+            $booksChapter = PaliText::select(['book', 'paragraph'])
+                ->where('level', 1)
                 ->get();
                 ->get();
         }
         }
+
+        $chapters = [];
+        foreach ($booksChapter as $key => $value) {
+            $chapters[] = [$value->book, $value->paragraph];
+        }
+        $books = ProgressChapter::with('channel.owner')
+            ->whereHas('channel', function ($query) use ($filters) {
+                $query->where('status', 30);
+
+                if ($filters['type'] !== 'all') {
+                    $query->where('type', $filters['type']);
+                }
+
+                if ($filters['lang'] !== 'all') {
+                    $query->where('lang', $filters['lang']);
+                }
+            })
+            ->where('progress', '>', config('mint.library.list_min_progress'))
+            ->whereIns(['book', 'para'], $chapters)
+            ->take(100)
+            ->get();
         return $this->getBooksInfo($books);
         return $this->getBooksInfo($books);
     }
     }
 
 

+ 200 - 66
api-v12/app/Http/Controllers/Library/WikiController.php

@@ -6,11 +6,15 @@ use App\Http\Controllers\Controller;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use App\Helpers\WikiContentParser;
 use App\Helpers\WikiContentParser;
 use App\Services\TermService;
 use App\Services\TermService;
+use Illuminate\Support\Str;
+use App\Services\OpenSearchService;
+
 
 
 class WikiController extends Controller
 class WikiController extends Controller
 {
 {
     public function __construct(
     public function __construct(
-        private TermService    $termService
+        private TermService    $termService,
+        private OpenSearchService    $searchService
     ) {}
     ) {}
 
 
     // ── Mock 数据 ────────────────────────────────────────────────
     // ── Mock 数据 ────────────────────────────────────────────────
@@ -49,76 +53,172 @@ HTML,
         ];
         ];
     }
     }
 
 
-    private function featured(array $all): array
-    {
-        return array_map(function ($item) {
-            return ['word' => $item['word'],     'zh' => $item['meaning'],   'lang' => $item['language'], 'category' => '法义术语'];
-        }, $all);
-    }
 
 
-    private function mockStats(): array
+
+    // ── Actions ──────────────────────────────────────────────────
+
+    public function index(Request $request, string $lang)
     {
     {
-        return [
-            'total'        => 2847,
-            'this_month'   => 43,
-            'contributors' => 128,
+        $quality = $request->input('quality')
+            ?? $request->cookie('wiki_quality_filter')
+            ?? 'draft';
+
+        $cookie = cookie()->forever('wiki_quality_filter', $quality);
+
+        $category = $request->input('category');
+        $subs     = null;
+
+        if ($category) {
+            $taxNode = collect(config('taxonomy'))->firstWhere('id', $category);
+            $subs    = $taxNode ? $this->subEntries($taxNode['subs'], $lang, $quality) : null;
+        }
+
+        $result      = $this->termService->communityTerms($lang);
+        $fakeRequest = Request::create('', 'GET', []);
+        $terms       = $result['data']->toArray($fakeRequest);
+        $first       = $terms[0];
+
+        $today = [
+            'word'     => $first['word'],
+            'lang'     => $first['language'],
+            'slug'     => $first['word'],
+            'meaning'  => $first['meaning'],
+            'quality'  => 'featured',
+            'category' => '法义术语',
+            'content'  => $first['summary'],
         ];
         ];
+
+
+
+        return response()
+            ->view('library.wiki.index', [
+                'today'         => $request->has('category') ? null : $today,
+                'featured'      => $category ? null : $this->featured($terms),
+                'stats'         => $this->mockStats(),
+                'recentUpdates' => $this->mockRecentUpdates(),
+                'categories'    => $this->categories(),
+                'lang'          => $lang,
+                'category'      => $category,
+                'subs'          => $subs,   // null | array of subs with entries
+                'quality'   => $quality,
+                'qualities' => $this->qualities(),
+            ])->withCookie($cookie);
     }
     }
 
 
-    private function mockRecentUpdates(): array
+    /**
+     * 给每个二级分类注入 词条列表
+     * 真实数据替换时:按 sub['tags'] 查询术语表,返回同结构数组即可
+     */
+    private function subEntries(array $subs, string $lang, string $quality): array
     {
     {
-        return [
-            ['word' => 'Nibbāna',  'lang' => 'pi'],
-            ['word' => '四圣谛',   'lang' => 'zh'],
-            ['word' => '阿含经',   'lang' => 'zh'],
-            ['word' => 'Rājagaha', 'lang' => 'pi'],
-        ];
+        return array_map(function ($sub) use ($lang, $quality) {
+            $entries = $this->querySubCat($sub['tags'], $lang, $quality);
+            return array_merge($sub, ['entries' => $entries]);
+        }, $subs);
     }
     }
 
 
-    // ── Actions ──────────────────────────────────────────────────
-
-    public function index(string $lang)
+    private function querySubCat(array $cats, string $lang, string $quality): array
     {
     {
-        $result = $this->termService->communityTerms($lang);
-        $fakeRequest = Request::create('', 'GET', []);
-        $termResource = $result['data'];
-        $terms    = $termResource->toArray($fakeRequest);
+        $params = [
+            'pageSize'     => 1000,
+            'resourceType' => 'term',
+            'language'     => $lang,
+            'tags'         => array_map(fn($n) => "category:{$n}", $cats),
+        ];
 
 
-        $first = $terms[0];
-        $today = [
-            'word'      => $first['word'],
-            'lang'      => $first['language'],
-            'slug'      => $first['word'],
-            'meaning'        => $first['meaning'],
-            'quality'   => 'featured',   // featured | stub | review | null
-            'category'  => '法义术语',
-            'content' => $first['summary']
+        $result = $this->searchService->search($params);
+
+        // 质量等级(数值越小等级越高)
+        $qualityRank = [
+            'featured' => 1,
+            'standard' => 2,
+            'draft'    => 3,
+            'pending'  => 4,
         ];
         ];
 
 
+        // 当前允许的最大等级
+        $maxRank = $qualityRank[$quality] ?? 4;
 
 
-        return view('library.wiki.index', [
-            'today'         => $today,
-            'featured'      => $this->featured($terms),
-            'stats'         => $this->mockStats(),
-            'recentUpdates' => $this->mockRecentUpdates(),
-            'categories'    => $this->categories(),
-            'lang' => $lang
-        ]);
+        $unique = [];
+
+        foreach ($result['hits']['hits'] as $item) {
+            $text = $item['_source']['title']['text'];
+            $id   = $item['_source']['resource_id'];
+
+            if (isset($item['_source']['tags'])) {
+                $itemQuality = $this->getQuality($item['_source']['tags']) ?? 'pending';
+            } else {
+                $itemQuality = 'pending';
+            }
+
+            $itemRank = $qualityRank[$itemQuality] ?? 4;
+
+            // 按输入质量过滤
+            if ($itemRank > $maxRank) {
+                continue;
+            }
+
+            $record = [
+                'id'      => $id,
+                'word'    => $text['pali'],
+                'zh'      => $text['zh'],
+                'quality' => $itemQuality,
+            ];
+
+            // 用 pali + zh 去重
+            $key = mb_strtolower(trim($text['pali']) . '|' . trim($text['zh']));
+
+            // 如果不存在,直接保存
+            if (!isset($unique[$key])) {
+                $unique[$key] = $record;
+                continue;
+            }
+
+            // 已存在时,保留质量更高的
+            $existingQuality = $unique[$key]['quality'];
+            $existingRank    = $qualityRank[$existingQuality] ?? 4;
+
+            if ($itemRank < $existingRank) {
+                $unique[$key] = $record;
+            }
+        }
+
+        return array_values($unique);
+    }
+
+    private function getQuality(array $tags)
+    {
+        $qualityTag = array_find($tags, function ($tag) {
+            return str_contains($tag, 'quality:');
+        });
+        if ($qualityTag) {
+            return mb_substr($qualityTag, 8, null, "UTF-8");
+        } else {
+            return null;
+        }
     }
     }
 
 
+
     public function show(string $lang, string $word)
     public function show(string $lang, string $word)
     {
     {
+        if (Str::isUuid($word)) {
+            $term = $this->termService->find($word, 'html');
+        } else {
+            $term = $this->termService->communityWiki($word, $lang, 'html');
+        }
 
 
-        $term = $this->termService->communityTerm($word, $lang);
-        $urlParam = ['format' => 'html'];
-        $fakeRequest = Request::create('', 'GET', $urlParam);
-        $termArray    = $term->toArray($fakeRequest);
+        $result = $this->searchService->get("term_{$term['guid']}");
+        if (isset($result['_source']['tags'])) {
+            $quality = $this->getQuality($result['_source']['tags']);
+        } else {
+            $quality = null;
+        }
         $entry = [
         $entry = [
-            'word'      => $termArray['word'],
-            'lang'      => $termArray['language'],
-            'slug'      => $termArray['word'],
-            'meaning'        => $termArray['meaning'],
-            'quality'   => 'featured',   // featured | stub | review | null
+            'word'      => $term['word'],
+            'lang'      => $term['language'],
+            'slug'      => $term['word'],
+            'meaning'        => $term['meaning'],
+            'quality'   => $quality,   // featured | standard | draft | pending | null
             'category'  => '法义术语',
             'category'  => '法义术语',
             'tags'      => [],
             'tags'      => [],
             'langs'     => [
             'langs'     => [
@@ -127,11 +227,8 @@ HTML,
             ],
             ],
             'related' => [
             'related' => [
                 ['word' => 'Dukkha',    'zh' => '苦',   'lang' => 'pi'],
                 ['word' => 'Dukkha',    'zh' => '苦',   'lang' => 'pi'],
-                ['word' => 'Anattā',    'zh' => '无我', 'lang' => 'pi'],
-                ['word' => 'Vipassanā', 'zh' => '内观', 'lang' => 'pi'],
-                ['word' => 'Ti-lakkhaṇa', 'zh' => '三相', 'lang' => 'pi'],
             ],
             ],
-            'content' => $termArray['html']
+            'content' => $term['html'] ?? ''
         ];
         ];
         $parsed  = WikiContentParser::parse($entry['content']);
         $parsed  = WikiContentParser::parse($entry['content']);
 
 
@@ -139,25 +236,25 @@ HTML,
             'entry' => array_merge($entry, [
             'entry' => array_merge($entry, [
                 'content' => $parsed['content'],
                 'content' => $parsed['content'],
                 'toc'     => $parsed['toc'],
                 'toc'     => $parsed['toc'],
+                'edit_url' => config('mint.server.dashboard_base_path') . "/workspace/term/{$term['guid']}/edit",
+                'zh' => '编辑'
             ]),
             ]),
             'categories' => $this->categories(),
             'categories' => $this->categories(),
-            'lang' => $lang
+            'lang' => $lang,
+
         ]);
         ]);
     }
     }
 
 
+
     // ── Helpers ──────────────────────────────────────────────────
     // ── Helpers ──────────────────────────────────────────────────
 
 
     private function categories(): array
     private function categories(): array
     {
     {
-        return [
-            ['slug' => 'all',      'label' => '全部'],
-            ['slug' => 'term',     'label' => '法义术语'],
-            ['slug' => 'person',   'label' => '人物传记'],
-            ['slug' => 'text',     'label' => '经典文献'],
-            ['slug' => 'school',   'label' => '宗派历史'],
-            ['slug' => 'practice', 'label' => '修行方法'],
-            ['slug' => 'place',    'label' => '佛教地理'],
-        ];
+        $cats = collect(config('taxonomy'))
+            ->map(fn($cat) => ['id' => $cat['id'], 'label' => $cat['label']])
+            ->toArray();
+
+        return $cats;
     }
     }
 
 
     // 在 WikiController.php 中添加此方法
     // 在 WikiController.php 中添加此方法
@@ -204,4 +301,41 @@ HTML,
             'dailyTerm'     => $dailyTerm,
             'dailyTerm'     => $dailyTerm,
         ]);
         ]);
     }
     }
+
+
+    private function qualities(): array
+    {
+        return [
+            ['value' => 'featured', 'label' => '典范条目', 'subtitle' => '平台推荐'],
+            ['value' => 'standard',  'label' => '规范条目', 'subtitle' => '邀请试读'],
+            ['value' => 'draft',     'label' => '草稿', 'subtitle' => '仅供参考'],
+            ['value' => 'pending',   'label' => '待定', 'subtitle' => '审稿专用'],
+        ];
+    }
+
+    private function featured(array $all): array
+    {
+        return array_map(function ($item) {
+            return ['word' => $item['word'],     'zh' => $item['meaning'],   'lang' => $item['language'], 'category' => '法义术语'];
+        }, $all);
+    }
+
+    private function mockStats(): array
+    {
+        return [
+            'total'        => 2847,
+            'this_month'   => 43,
+            'contributors' => 128,
+        ];
+    }
+
+    private function mockRecentUpdates(): array
+    {
+        return [
+            ['word' => 'Nibbāna',  'lang' => 'pi'],
+            ['word' => '四圣谛',   'lang' => 'zh'],
+            ['word' => '阿含经',   'lang' => 'zh'],
+            ['word' => 'Rājagaha', 'lang' => 'pi'],
+        ];
+    }
 }
 }

+ 5 - 8
api-v12/app/Http/Controllers/SearchPlusController.php

@@ -4,20 +4,16 @@ namespace App\Http\Controllers;
 
 
 use App\Services\OpenSearchService;
 use App\Services\OpenSearchService;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
+use App\DTO\Search\HitItemDTO;
 
 
 class SearchPlusController extends Controller
 class SearchPlusController extends Controller
 {
 {
-    protected $searchService;
-
     /**
     /**
      * 构造函数,注入 OpenSearchService
      * 构造函数,注入 OpenSearchService
      *
      *
      * @param  \App\Services\OpenSearchService  $searchService
      * @param  \App\Services\OpenSearchService  $searchService
      */
      */
-    public function __construct(OpenSearchService $searchService)
-    {
-        $this->searchService = $searchService;
-    }
+    public function __construct(protected OpenSearchService $searchService) {}
 
 
     /**
     /**
      * Display a listing of the resource.
      * Display a listing of the resource.
@@ -58,8 +54,8 @@ class SearchPlusController extends Controller
         $resourceId = $request->input('resource_id'); // 资源类型
         $resourceId = $request->input('resource_id'); // 资源类型
         $granularity  = $request->input('granularity');   // 文档颗粒度
         $granularity  = $request->input('granularity');   // 文档颗粒度
         $language     = $request->input('language');      // 语言
         $language     = $request->input('language');      // 语言
-        $category     = $request->input('category');      // 分类
-        $tags         = $request->input('tags', []);      // 标签
+        $category     = $request->has('category') ? explode(',', $request->input('category')) : null;      // 分类
+        $tags         = $request->has('tags') ? explode(',', $request->input('tags')) : [];      // 标签
         $pageRefs     = $request->input('page_refs', []); // 页码标记
         $pageRefs     = $request->input('page_refs', []); // 页码标记
         $relatedId    = $request->input('related_id'); // 关联 ID
         $relatedId    = $request->input('related_id'); // 关联 ID
         $author       = $request->input('author');        // 作者/译者 (metadata.author)
         $author       = $request->input('author');        // 作者/译者 (metadata.author)
@@ -133,6 +129,7 @@ class SearchPlusController extends Controller
     public function show($id)
     public function show($id)
     {
     {
         //
         //
+        return $this->ok(HitItemDTO::fromArray($this->searchService->get($id)));
     }
     }
 
 
     /**
     /**

+ 21 - 17
api-v12/app/Http/Controllers/SearchSuggestController.php

@@ -11,7 +11,7 @@ use App\Services\OpenSearchService;
  *
  *
  * 返回示例:
  * 返回示例:
  *
  *
- * 请求:GET /api/v2/suggest?q=dhamma&fields=title,content&limit=10
+ * 请求:GET /api/v3/search-suggest?q=dhamma&fields=title,content&limit=10
  *
  *
  * 返回:
  * 返回:
  * {
  * {
@@ -19,22 +19,26 @@ use App\Services\OpenSearchService;
  *   "data": {
  *   "data": {
  *     "query": "dhamma",
  *     "query": "dhamma",
  *     "suggestions": [
  *     "suggestions": [
- *       {
- *         "text": "dhammapada",
- *         "source": "title",
- *         "score": 5.2,
- *         "resource_type": "sutta",
- *         "language": "pali",
- *         "doc_id": "doc_123"
- *       },
- *       {
- *         "text": "dhammapadā",
- *         "source": "content",
- *         "score": 4.8,
- *         "resource_type": "commentary",
- *         "language": "pali",
- *         "doc_id": "doc_456"
- *       }
+      {
+        "text": "dhammacakkapavattanasutta",
+        "source": "content",
+        "score": 1,
+        "resource_type": "term",
+        "language": "zh",
+        "doc_id": "term_69258244-bccd-40ed-bfaa-ddef4ae5ae4c",
+        "category": [],
+        "granularity": null
+      },
+      {
+        "text": "dhammacakkappavattanasutta",
+        "source": "content",
+        "score": 1,
+        "resource_type": "term",
+        "language": "zh-hans",
+        "doc_id": "term_bcb14399-ea80-4a8a-aeab-a4c927e45fdd",
+        "category": [],
+        "granularity": null
+      }
  *     ],
  *     ],
  *     "total": 2
  *     "total": 2
  *   }
  *   }

+ 4 - 3
api-v12/app/Http/Resources/TermResource.php

@@ -35,6 +35,7 @@ class TermResource extends JsonResource
             "tag" => $this->tag,
             "tag" => $this->tag,
             "note" => $this->note,
             "note" => $this->note,
             "language" => $this->language,
             "language" => $this->language,
+            "channel_id" => $this->channal,
             "channal" => $this->channal,
             "channal" => $this->channal,
             "studio" => StudioApi::getById($this->owner),
             "studio" => StudioApi::getById($this->owner),
             "editor" => UserApi::getById($this->editor_id),
             "editor" => UserApi::getById($this->editor_id),
@@ -46,9 +47,9 @@ class TermResource extends JsonResource
         if ($request->has('channel') && !empty($request->input('channel'))) {
         if ($request->has('channel') && !empty($request->input('channel'))) {
             $channels = explode('_', $request->input('channel'));
             $channels = explode('_', $request->input('channel'));
         } else {
         } else {
-            if (!empty($this->channal) && Str::isUuid($this->channal)) {
-                $channelId = $this->channal;
-                $data["channel"] = ChannelApi::getById($this->channal);
+            if (!empty($this->channel_id) && Str::isUuid($this->channel_id)) {
+                $channelId = $this->channel_id;
+                $data["channel"] = ChannelApi::getById($this->channel_id);
             } else {
             } else {
                 $channelId = ChannelApi::getSysChannel('_community_translation_' . $this->language . '_');
                 $channelId = ChannelApi::getSysChannel('_community_translation_' . $this->language . '_');
                 if (empty($channelId)) {
                 if (empty($channelId)) {

+ 30 - 0
api-v12/app/Models/PaliText.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Builder;
 
 
 class PaliText extends Model
 class PaliText extends Model
 {
 {
@@ -21,4 +22,33 @@ class PaliText extends Model
     {
     {
         return $this->hasMany(TagMap::class, 'anchor_id', 'uid');
         return $this->hasMany(TagMap::class, 'anchor_id', 'uid');
     }
     }
+
+    public function scopeWithAllTags(Builder $query, array $tagNames): Builder
+    {
+        foreach ($tagNames as $name) {
+            $query->whereHas('tagMaps.tags', function ($q) use ($name) {
+                $q->where('name', $name);
+            });
+        }
+        return $query;
+    }
+
+    /**
+     * 标签多的优化版
+     *     public function scopeWithAllTags(Builder $query, array $tagNames): Builder
+    {
+        $count = count($tagNames);
+
+        $query->whereIn('uid', function ($sub) use ($tagNames, $count) {
+            $sub->select('tm.anchor_id')
+                ->from('tag_maps as tm')
+                ->join('tags as t', 't.id', '=', 'tm.tag_id')
+                ->whereIn('t.name', $tagNames)
+                ->groupBy('tm.anchor_id')
+                ->havingRaw('COUNT(DISTINCT t.name) = ?', [$count]);
+        });
+
+        return $query;
+    }
+     */
 }
 }

+ 1 - 1
api-v12/app/Models/TagMap.php

@@ -22,6 +22,6 @@ class TagMap extends Model
 
 
     public function tags()
     public function tags()
     {
     {
-        return $this->hasOne('App\Models\Tag', 'id', 'tag_id');
+        return $this->belongsTo(Tag::class, 'tag_id', 'id');
     }
     }
 }
 }

+ 250 - 0
api-v12/app/Services/AIAssistant/AITermService.php

@@ -0,0 +1,250 @@
+<?php
+
+namespace App\Services\AIAssistant;
+
+use App\Services\OpenSearchService;
+use App\Services\TermService;
+use App\Services\OpenAIService;
+use App\Services\AIModelService;
+use App\Http\Resources\AiModelResource;
+use App\Http\Controllers\AuthController;
+use App\DTO\Search\SearchDataDTO;
+
+class AITermService
+{
+    protected $pageSize = 20;
+    protected AiModelResource $model;
+
+
+    protected $modelToken;
+
+
+    private $sysPrompt = <<<md
+    请根据提供的文献搜素结果,撰写一个巴利术语的简体中文百科词条。
+
+    搜素结果是json数组
+    字段
+    - title(标题)
+    - content:(内容)
+    - path(章节路径)
+    - link(引用链接)
+
+    要求:
+    1. 参考维基百科的形式和结构
+    2. 所有观点必须标明巴利文出处,使用我提供的link
+    3. 引用巴利文原文时使用引号并斜体
+    4. 提供完整的参考文献列表
+    5. 保持学术中立性和客观性
+    6. 请引用我提供的全部内容,不要有任何遗漏
+    7. 请在文档的开头输出一个模板 {{quality|pending}}
+
+    **观点引用标准格式:**
+    《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。(link引用链接)
+
+    如果某个观点有多个出处,请分别列出巴利文引用链接。范例
+    《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。(link引用链接1)(link引用链接2)
+    示例:
+    《疑惑度脱新注》在《染色学处注释》中指出:"*Kiriyākiriyanti nivāsanapārupanato, kappassa anādānato kiriyākiriyaṃ*"[9](穿着下衣、披上衣是作为,不采取如法措施是不作为,故为作为-不作为)。{{para|id=202-1878|title=202-1878|style=reference}}
+
+        **引用处理规则(按优先级):**
+
+    1. 【有明确论断句】直接引用该句巴利文:
+    《X》在《Y》中指出:"巴利文原文"(译文)。(link)
+
+    2. 【叙事段落,无单一论断句】从段落中选取最能代表该段核心意思的
+    一个完整句子作为代表句引用,不得跳过巴利文:
+    《X》在《Y》中记载,[中文概述主要内容];原文云:"巴利文代表句"
+    (该句译文)。(link)
+
+    3. 【段落过长】从原文中截取开头或核心句,以省略号表示省略:
+    "巴利文开头...(省略)"(译文说明省略范围)。(link)
+
+    **绝对禁止:** 任何观点陈述只有中文转述而没有对应巴利文引用。
+    如确实无法提取,须注明"(原文为纯叙事,节录如下)"并仍须给出
+    原文片段。
+
+    **输出前自检:**
+    逐条检查每一个观点陈述,确认是否符合以下格式:
+    [中文陈述] + "巴利文" + (译文) + (link)
+    若有不符合的条目,返回修改后再输出。
+
+    词条结构应包括:
+    - 简短定义段落(不要标题直接输出段落内容)
+    - 目录
+    - 词源与定义
+    - 其他的,文献中提及的内容分类
+    - 参考文献
+    - 相关条目
+    - 分类标签
+
+    格式要求:
+    - 使用Markdown格式
+    - 标题层级清晰(#, ##, ###)
+    - 直接输出百科正文,无需大标题
+    - 引用格式:《文献中文名》在《章节中文名》中 + 动词 + "巴利文"  + (巴利文的中文译文)(link引用链接)
+    - 引用动词可用:指出、解释、说明、定义、描述、强调、阐述、论述等
+    - 巴利文使用罗马转写
+    - 关键术语首次出现时提供巴利文和中文对照
+
+    参考文献格式:
+    [序号] 文献全称缩写, 具体章节, 标题, 段落编号
+
+    请在文档的底部输出分类标签
+
+    请根据以下分类体系,为给定的词条打上分类标签。
+
+    **分类体系:**
+
+    一、经藏教义:1.1基本教义(苦、集、灭、道、缘起、十二缘起、三法印、无常、无我、五蕴、三宝、八正道、中道、善法、不善法)/1.2业与轮回(业、善业、不善业、无记业、业报、轮回、结生、再生、三界轮转)/1.3涅槃与解脱(涅槃、有余涅槃、无余涅槃、解脱、道、果、烦恼断除、结、漏)
+
+    二、阿毗达摩:2.1心路与心类(欲界心、色界心、无色界心、出世间心、善心、不善心、无记心、心路、五门心路、意门心路、速行、有分、结生心、死心、转向心)/2.2心所法(遍一切心心所、杂心所、不善心所、美心所、贪、嗔、痴、慢、邪见、掉举、信、念、慧、悲、喜、舍)/2.3色法(四大种、地界、水界、火界、风界、净色、所造色、色聚、业生色、心生色、时节生色、食生色、真实色、非真实色)/2.4缘起与发趣法(二十四缘、因缘、所缘缘、增上缘、俱生缘、亲依止缘、前生缘、后生缘、业缘、果报缘、根缘、禅缘、道缘、相应缘、不相应缘、有缘、无有缘)
+
+    三、禅修:3.1止禅(遍禅、不净观、随念、四梵住、入出息念、四界差别、禅相、取相、似相、近行定、安止定、禅那、禅支、无色定)/3.2观禅(名色分别、观智、生灭智、坏灭智、怖畏智、厌离智、行舍智、道智、果智、毘婆舍那、三相、无常随观、苦随观、无我随观、刹那定)
+
+    四、律学:4.1戒条罪类(波罗夷、僧残、不定、舍堕、单堕、悔过、众学、灭诤、比库戒、比库尼戒、学处、犯罪、无犯、违犯条件)/4.2僧团制度与羯磨(羯磨、白羯磨、白二羯磨、白四羯磨、结界、布萨、自恣、受具足、出家、僧团、四方僧、惩罚羯磨、和合)/4.3僧侣生活与器具(三衣、钵、住处、精舍、雨安居、迦提那衣、头陀行、乞食、日用器具、净食)
+
+    五、经典与文献:5.1经藏(长部、中部、相应部、增支部、小部、经名、品名、篇名)/5.2论藏与注疏(论藏、七论、义注、复注、清净道论、摄论、史书、藏外文献)/5.3本生与偈颂(本生、长老偈、长老尼偈、佛种姓、譬喻、天宫事、饿鬼事)
+
+    六、世界观:6.1三界与诸天(欲界、色界、无色界、欲界天、梵天界、净居天、人间、有情居、天界层次)/6.2地狱与恶趣(地狱、无间地狱、寒冰地狱、孤独地狱、饿鬼界、畜生界、阿修罗界、四恶趣)/6.3神灵与非人(天神、梵天、夜叉、龙族、乾达婆、非人、护法神)
+
+    七、人物:7.1佛(佛名、过去佛、二十八佛、独觉佛、菩萨)/7.2出家弟子(上首弟子、大弟子、比库弟子、比库尼弟子、沙弥、沙弥尼、在学尼)/7.3在家人(男居士、女居士、护法者、国王、婆罗门、施主、转轮圣王、王后、大长者)/7.4外道(外道名)
+
+    八、地理:8.1国家与城镇(十六大国、国家、村落、市镇、寺院名)/8.2水系(河流、湖泊、海洋)/8.3山岳(山名、山脉)
+
+    九、动植物:9.1动物(兽类、鸟类、爬行类、鱼类、昆虫、神话动物、龙族、金翅鸟、畜养动物、野生动物)/9.2植物(树木、花卉、草药、粮食作物、果实、圣树、菩提树类)
+
+    十、巴利语言与语法:10.1语法术语(名词、动词、形容词、副词、格、数、性、时态、语式、复合词类型、前缀、后缀)/10.2语法缩写与标注(词性标注、格标注、语态标注、使役态、引用标记、出处标注)
+
+    **输出规则:**
+    - 必须从以上分类体系中选取,不得自创分类
+    - 一个词条可打多个标签,但通常不超过3个
+    - 先输出一级分类,再输出二级分类,再输出具体标签
+    - 格式严格为:{{category|一级分类}} {{category|二级分类}} {{category|标签}}
+    - 一级分类去除编号,例如"人物"而非"七、人物"
+    - 二级分类去除编号,例如"佛"而非"7.1佛"
+    - 只输出标签,不输出任何解释
+
+    ---
+
+    输出示例:
+
+    # 分类标签
+
+    {{category|经典与文献}} {{category|经藏}} {{category|中部}} {{category|义注}}
+    md;
+
+    private $sysPromptTags = <<<md
+    你是一个巴利语佛教百科词条分类专家。请根据以下分类体系,为给定的词条打上分类标签。
+
+    在分类标签的前面添加一个**词条概述**
+
+    分类标签
+
+    请根据以下分类体系,为给定的词条打上分类标签。
+
+    **分类体系:**
+
+    一、经藏教义:1.1基本教义(苦、集、灭、道、缘起、十二缘起、三法印、无常、无我、五蕴、三宝、八正道、中道、善法、不善法)/1.2业与轮回(业、善业、不善业、无记业、业报、轮回、结生、再生、三界轮转)/1.3涅槃与解脱(涅槃、有余涅槃、无余涅槃、解脱、道、果、烦恼断除、结、漏)
+
+    二、阿毗达摩:2.1心路与心类(欲界心、色界心、无色界心、出世间心、善心、不善心、无记心、心路、五门心路、意门心路、速行、有分、结生心、死心、转向心)/2.2心所法(遍一切心心所、杂心所、不善心所、美心所、贪、嗔、痴、慢、邪见、掉举、信、念、慧、悲、喜、舍)/2.3色法(四大种、地界、水界、火界、风界、净色、所造色、色聚、业生色、心生色、时节生色、食生色、真实色、非真实色)/2.4缘起与发趣法(二十四缘、因缘、所缘缘、增上缘、俱生缘、亲依止缘、前生缘、后生缘、业缘、果报缘、根缘、禅缘、道缘、相应缘、不相应缘、有缘、无有缘)
+
+    三、禅修:3.1止禅(遍禅、不净观、随念、四梵住、慈、悲、喜、舍、入出息念、四界差别、禅相、取相、似相、近行定、安止定、禅那、禅支、无色定)/3.2观禅(名色分别、观智、生灭智、坏灭智、怖畏智、厌离智、行舍智、道智、果智、毘婆舍那、三相、无常随观、苦随观、无我随观、刹那定)
+
+    四、律学:4.1戒条罪类(波罗夷、僧残、不定、舍堕、单堕、悔过、众学、灭诤、比库戒、比库尼戒、学处、犯罪、无犯、违犯条件)/4.2僧团制度与羯磨(羯磨、白羯磨、白二羯磨、白四羯磨、结界、布萨、自恣、受具足、出家、僧团、四方僧、惩罚羯磨、和合)/4.3僧侣生活与器具(三衣、钵、住处、精舍、雨安居、迦提那衣、头陀行、乞食、日用器具、净食)
+
+    五、经典与文献:5.1经藏(长部、中部、相应部、增支部、小部、经名、品名、篇名)/5.2论藏与注疏(论藏、七论、义注、复注、清净道论、摄论、史书、藏外文献)/5.3本生与偈颂(本生、长老偈、长老尼偈、佛种姓、譬喻、天宫事、饿鬼事)
+
+    六、世界观:6.1三界与诸天(欲界、色界、无色界、欲界天、梵天界、净居天、人间、有情居、天界层次)/6.2地狱与恶趣(地狱、无间地狱、寒冰地狱、孤独地狱、饿鬼界、畜生界、阿修罗界、四恶趣)/6.3神灵与非人(天神、梵天、夜叉、龙族、乾达婆、非人、护法神)
+
+    七、人物:7.1佛(佛名、过去佛、二十八佛、独觉佛、菩萨)/7.2出家弟子(上首弟子、大弟子、比库弟子、比库尼弟子、沙弥、沙弥尼、在学尼)/7.3在家人(男居士、女居士、护法者、国王、婆罗门、施主、转轮圣王、王后、大长者)/7.4外道(外道名)
+
+    八、地理:8.1国家与城镇(十六大国、国家、村落、市镇、寺院名)/8.2水系(河流、湖泊、海洋)/8.3山岳(山名、山脉)
+
+    九、动植物:9.1动物(兽类、鸟类、爬行类、鱼类、昆虫、神话动物、龙族、金翅鸟、畜养动物、野生动物)/9.2植物(树木、花卉、草药、粮食作物、果实、圣树、菩提树类)
+
+    十、巴利语言与语法:10.1语法术语(名词、动词、形容词、副词、格、数、性、时态、语式、复合词类型、前缀、后缀)/10.2语法缩写与标注(词性标注、格标注、语态标注、使役态、引用标记、出处标注)
+
+    **输出规则:**
+    - 必须从以上分类体系中选取,不得自创分类
+    - 一个词条可打多个标签,但通常不超过3个
+    - 先输出一级分类,再输出二级分类,再输出具体标签
+    - 格式严格为:`{{category|一级分类}}` `{{category|二级分类}}` `{{category|标签}}`
+    - 一级分类去除编号,例如"人物"而非"七、人物"
+    - 二级分类去除编号,例如"佛"而非"7.1佛"
+    - 只输出标签,不输出任何解释
+
+    ---
+
+    输出示例:
+
+    (词条的概述)
+
+    # 分类标签
+
+    {{category|经典与文献}} {{category|经藏}} {{category|中部}} {{category|义注}}
+    md;
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected AIModelService $modelService,
+        protected OpenAIService $openAIService,
+        protected TermService $termService
+    ) {}
+    public function setModel($id)
+    {
+        $this->model = $this->modelService->getModelById($id);
+        $this->modelToken = AuthController::getUserToken($id);
+        return $this;
+    }
+
+    private function query(string $word): array
+    {
+        $search = app(OpenSearchService::class);
+        // 组装搜索参数
+        $params = [
+            'query'        => $word,
+            'pageSize'     => $this->pageSize,
+        ];
+        $result = $search->search($params);
+
+        $dto = SearchDataDTO::fromArray($result);
+        $res = array();
+        foreach ($dto->hits->items as $key => $item) {
+            $res[] = [
+                'title' => $item->title,
+                'content' => $item->content,
+                'path' => $item->path,
+                'link' => $item->getParaLink()
+            ];
+        }
+        return $res;
+    }
+
+    public function update(string $id)
+    {
+        // 获取术语
+        $term = $this->termService->getRaw($id);
+        // 全文搜索
+        $query = $this->query($term->word);
+        $res = json_encode($query, JSON_UNESCAPED_UNICODE);
+        //LLM 生成
+        $response = $this->openAIService->setApiUrl($this->model['url'])
+            ->setModel($this->model['model'])
+            ->setApiKey($this->model['key'])
+            ->setSystemPrompt($this->sysPrompt)
+            ->setTemperature(0.5)
+            ->setStream(false)
+            ->send(
+                "# 巴利术语\n\n{$term->word}\n\n"
+            );
+
+        $content = $response['choices'][0]['message']['content'] ?? '';
+        $this->termService->update($id, ['note' => $content]);
+        return $content;
+    }
+    public function create(string $word) {}
+}

+ 0 - 133
api-v12/app/Services/AITermService.php

@@ -1,133 +0,0 @@
-<?php
-
-namespace App\Services;
-
-use App\Services\OpenSearchService;
-use App\Services\TermService;
-use App\Services\OpenAIService;
-use App\Services\AIModelService;
-use App\Http\Resources\AiModelResource;
-use App\Http\Controllers\AuthController;
-use App\DTO\Search\SearchDataDTO;
-
-class AITermService
-{
-    protected $pageSize = 20;
-    protected AiModelResource $model;
-
-    protected $modelService;
-    protected $modelToken;
-    protected $openAIService;
-
-    private $sysPrompt = <<<md
-    请根据提供的文献搜素结果,撰写一个巴利术语的简体中文百科词条。
-
-    搜素结果是json数组
-    字段
-    - title(标题)
-    - content:(内容)
-    - path(章节路径)
-    - link(引用链接)
-
-    要求:
-    1. 参考维基百科的形式和结构
-    2. 所有观点必须标明巴利文出处,使用我提供的link
-    3. 引用巴利文原文时使用引号并斜体
-    4. 提供完整的参考文献列表
-    5. 保持学术中立性和客观性
-    6. 请引用我提供的全部内容,不要有任何遗漏
-
-    **观点引用标准格式:**
-    《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。(link引用链接)
-
-    如果某个观点有多个出处,请分别列出巴利文引用链接。范例
-    《文献中文名》在《章节中文名》中指出/解释/说明:"巴利文原文"(中文翻译及必要说明)。(link引用链接1)(link引用链接2)
-    示例:
-    《疑惑度脱新注》在《染色学处注释》中指出:"*Kiriyākiriyanti nivāsanapārupanato, kappassa anādānato kiriyākiriyaṃ*"[9](穿着下衣、披上衣是作为,不采取如法措施是不作为,故为作为-不作为)。{{para|id=202-1878|title=202-1878|style=reference}}
-
-    词条结构应包括:
-    - 标题(术语的巴利语、字面含义)
-    - 简短定义段落
-    - 目录
-    - 词源与定义
-    - 其他的,文献中提及的内容分类
-    - 参考文献
-    - 相关条目
-    - 分类标签
-
-    格式要求:
-    - 使用Markdown格式
-    - 标题层级清晰(#, ##, ###)
-    - 直接输出百科正文,无需大标题
-    - 引用格式:《文献中文名》在《章节中文名》中 + 动词 + "巴利文"  + (巴利文的中文译文)(link引用链接)
-    - 引用动词可用:指出、解释、说明、定义、描述、强调、阐述、论述等
-    - 巴利文使用罗马转写
-    - 关键术语首次出现时提供巴利文和中文对照
-
-    参考文献格式:
-    [序号] 文献全称缩写, 具体章节, 标题, 段落编号
-    md;
-
-    /**
-     * Create a new command instance.
-     *
-     * @return void
-     */
-    public function __construct(
-        AIModelService $model,
-        OpenAIService $openAI,
-    ) {
-        $this->modelService = $model;
-        $this->openAIService = $openAI;
-    }
-    public function setModel($id)
-    {
-        $this->model = $this->modelService->getModelById($id);
-        $this->modelToken = AuthController::getUserToken($id);
-    }
-
-    private function query(string $word): array
-    {
-        $search = app(OpenSearchService::class);
-        // 组装搜索参数
-        $params = [
-            'query'        => $word,
-            'pageSize'     => $this->pageSize,
-        ];
-        $result = $search->search($params);
-
-        $dto = SearchDataDTO::fromArray($result);
-        $res = array();
-        foreach ($dto->hits->items as $key => $item) {
-            $res[] = [
-                'title' => $item->title,
-                'content' => $item->content,
-                'path' => $item->path,
-                'link' => $item->getParaLink()
-            ];
-        }
-        return $res;
-    }
-
-    public function create(string $id)
-    {
-        // 获取术语
-        $term = app(TermService::class)->getRaw($id);
-        // 全文搜索
-        $query = $this->query($term->word);
-        $res = json_encode($query, JSON_UNESCAPED_UNICODE);
-        //LLM 生成
-        $response = $this->openAIService->setApiUrl($this->model['url'])
-            ->setModel($this->model['model'])
-            ->setApiKey($this->model['key'])
-            ->setSystemPrompt($this->sysPrompt)
-            ->setTemperature(0.5)
-            ->setStream(false)
-            ->send("# 文献搜素结果\n\n{$res}\n\n" .
-                "# 巴利术语\n\n{$term->word}\n\n");
-
-        $content = $response['choices'][0]['message']['content'] ?? '';
-        return $content;
-    }
-    public function update() {}
-}

File diff suppressed because it is too large
+ 515 - 310
api-v12/app/Services/OpenSearchService.php


+ 18 - 7
api-v12/app/Services/SearchPaliDataService.php

@@ -111,9 +111,8 @@ class SearchPaliDataService
         $text = [];
         $text = [];
         $wordList = [];
         $wordList = [];
         foreach ($sentences as $key => $sentence) {
         foreach ($sentences as $key => $sentence) {
-            $content = $this->getSentenceText($book, $para, $sentence->word_begin, $sentence->word_end);
-            $id = "{$book}-{$para}-{$sentence->word_begin}-{$sentence->word_end}";
-            $markdown[] = "`id:{$id}`" . $content['markdown'];
+            $content = $this->getSentenceContent($book, $para, $sentence->word_begin, $sentence->word_end);
+            $markdown[] = $content['markdown'];
             $text[] = $content['text'];
             $text[] = $content['text'];
             $wordList = array_merge($wordList, $content['words']);
             $wordList = array_merge($wordList, $content['words']);
         }
         }
@@ -173,7 +172,7 @@ class SearchPaliDataService
      * @param int $para
      * @param int $para
      * @return array $sentence
      * @return array $sentence
      */
      */
-    public function getSentenceText($book, $para, $start, $end)
+    public function getSentenceContent($book, $para, $start, $end)
     {
     {
         $words = WbwTemplate::where('book', $book)
         $words = WbwTemplate::where('book', $book)
             ->where('paragraph', $para)
             ->where('paragraph', $para)
@@ -200,16 +199,28 @@ class SearchPaliDataService
                 $markdown .= $word->word . ' ';
                 $markdown .= $word->word . ' ';
             }
             }
         }
         }
-        $markdown = str_replace([' ti', ' ,', ' .', ' ?', '‘ ‘ ', ' ’ ’'], ['ti', ',', '.', '?', '‘‘', '’’'], $markdown);
+
+        //去掉多于的空格
+
+        $markdown = $this->removeSpace($markdown);
+        //合并连续的黑体
         $markdown = str_replace(['~~  ~~', '** **'], [' ', ' '], $markdown);
         $markdown = str_replace(['~~  ~~', '** **'], [' ', ' '], $markdown);
-        $text = implode(' ', $arrText);
-        $text = str_replace([' ti', ' ,', ' .', ' ?'], ['ti', ',', '.', '?'], $text);
+
+        $text = $this->removeSpace(implode(' ', $arrText));
         return [
         return [
             'markdown' => $this->abbrReplace(trim($markdown)),
             'markdown' => $this->abbrReplace(trim($markdown)),
             'text' => $this->abbrReplace($text),
             'text' => $this->abbrReplace($text),
             'words' => $wordList,
             'words' => $wordList,
         ];
         ];
     }
     }
+    private function removeSpace(string $input)
+    {
+        return str_replace(
+            [' ti', ' ,', ' .', ' ?', ' ;', '[ ', ' ]', '( ', ' )', '‘ ‘ ', ' ’ ’'],
+            ['ti', ',', '.', '?', ';', '[', ']', '(', ')', '‘‘', '’’'],
+            $input
+        );
+    }
     private function abbrReplace($input)
     private function abbrReplace($input)
     {
     {
         $abbr = ['sī .', 'syā .', 'kaṃ .', 'pī .'];
         $abbr = ['sī .', 'syā .', 'kaṃ .', 'pī .'];

+ 67 - 3
api-v12/app/Services/TermService.php

@@ -5,6 +5,7 @@ namespace App\Services;
 use App\Models\DhammaTerm;
 use App\Models\DhammaTerm;
 use App\Http\Api\ChannelApi;
 use App\Http\Api\ChannelApi;
 use App\Http\Resources\TermResource;
 use App\Http\Resources\TermResource;
+use Illuminate\Http\Request;
 
 
 
 
 class TermService
 class TermService
@@ -15,7 +16,7 @@ class TermService
             "_community_term_" . strtolower($lang) . "_",
             "_community_term_" . strtolower($lang) . "_",
             "_community_term_en_"
             "_community_term_en_"
         );
         );
-        $result = DhammaTerm::select(['word', 'tag', 'meaning', 'other_meaning'])
+        $result = DhammaTerm::select(['guid', 'word', 'tag', 'meaning', 'other_meaning'])
             ->where('channal', $localTermChannel)
             ->where('channal', $localTermChannel)
             ->get();
             ->get();
         return ['items' => $result, 'total' => count($result)];
         return ['items' => $result, 'total' => count($result)];
@@ -38,7 +39,19 @@ class TermService
         return $result;
         return $result;
     }
     }
 
 
-    public function communityTerm(string $word, string $lang)
+    public function isCommunity(?string $channelId)
+    {
+        $channel = ChannelApi::getById($channelId);
+        if (!$channel) {
+            return false;
+        }
+        if (strpos($channel['name'], '_community_term_') === false) {
+            return false;
+        } else {
+            return true;
+        }
+    }
+    public function communityTerm(string $word, string $lang, string $format)
     {
     {
         $localTermChannel = ChannelApi::getSysChannel(
         $localTermChannel = ChannelApi::getSysChannel(
             "_community_term_" . strtolower($lang) . "_"
             "_community_term_" . strtolower($lang) . "_"
@@ -47,11 +60,43 @@ class TermService
             ->where('channal', $localTermChannel)
             ->where('channal', $localTermChannel)
             ->first();
             ->first();
         if ($result) {
         if ($result) {
-            return new TermResource($result);
+            $resource = new TermResource($result);
+            $urlParam = ['format' => $format];
+            $fakeRequest = Request::create('', 'GET', $urlParam);
+            $termArray    = $resource->toArray($fakeRequest);
+            if ($result) {
+                return $termArray;
+            } else {
+                return null;
+            }
+        } else {
+            return null;
+        }
+    }
+
+    public function communityWiki(string $word, string $lang, string $format)
+    {
+        $localTermChannel = ChannelApi::getSysChannel(
+            "_community_translation_" . strtolower($lang) . "_"
+        );
+        $result = DhammaTerm::where('word', $word)
+            ->where('channal', $localTermChannel)
+            ->first();
+        if ($result) {
+            $resource = new TermResource($result);
+            $urlParam = ['format' => $format];
+            $fakeRequest = Request::create('', 'GET', $urlParam);
+            $termArray    = $resource->toArray($fakeRequest);
+            if ($result) {
+                return $termArray;
+            } else {
+                return null;
+            }
         } else {
         } else {
             return null;
             return null;
         }
         }
     }
     }
+
     public function communityTerms(string $lang)
     public function communityTerms(string $lang)
     {
     {
         $localTermChannel = ChannelApi::getSysChannel(
         $localTermChannel = ChannelApi::getSysChannel(
@@ -68,4 +113,23 @@ class TermService
             "count" => 10
             "count" => 10
         ];
         ];
     }
     }
+
+    public function find(string $id, string $format): ?array
+    {
+        $result = DhammaTerm::find($id);
+        $resource = new TermResource($result);
+        $urlParam = ['format' => $format];
+        $fakeRequest = Request::create('', 'GET', $urlParam);
+        $termArray    = $resource->toArray($fakeRequest);
+        if ($result) {
+            return $termArray;
+        } else {
+            return null;
+        }
+    }
+
+    public function update(string $id, array $data)
+    {
+        DhammaTerm::where('guid', $id)->update($data);
+    }
 }
 }

+ 1 - 1
api-v12/config/mint.php

@@ -151,7 +151,7 @@ return [
         ],
         ],
     ],
     ],
     'opensearch' => [
     'opensearch' => [
-        'index' => 'wikipali_20260423',
+        'index' => 'wikipali_20260424',
         'config' => [
         'config' => [
             'scheme' => env('OPENSEARCH_SCHEME', 'http'),
             'scheme' => env('OPENSEARCH_SCHEME', 'http'),
             'host' => env('OPENSEARCH_HOST', '127.0.0.1'),
             'host' => env('OPENSEARCH_HOST', '127.0.0.1'),

+ 254 - 0
api-v12/config/taxonomy.php

@@ -0,0 +1,254 @@
+<?php
+
+/**
+ * 佛教百科分类体系
+ * WikiPali Taxonomy Configuration
+ *
+ * 结构:一级分类 > 二级分类 > 标签列表
+ * 用法:config('taxonomy') 或 TaxonomyController 调用
+ * // 取全部分类
+$taxonomy = config('taxonomy');
+
+// 取所有一级分类标签(用于导航)
+$categories = collect(config('taxonomy'))->pluck('label', 'id');
+
+// 取某个一级分类下的二级分类
+$subs = collect(config('taxonomy'))
+    ->firstWhere('id', 'abhidhamma')['subs'];
+
+// 取所有标签(扁平化,用于搜索/自动补全)
+$allTags = collect(config('taxonomy'))
+    ->flatMap(fn($cat) => collect($cat['subs'])
+        ->flatMap(fn($sub) => $sub['tags']))
+    ->unique()
+    ->values();
+ */
+
+return [
+    [
+        'id'    => 'vinaya',
+        'label' => '律学',
+        'subs'  => [
+            [
+                'id'    => 'āpatti',
+                'label' => '戒条罪类',
+                'tags'  => ['波罗夷', '僧残', '不定', '舍堕', '单堕', '悔过', '众学', '灭诤', '比库戒', '比库尼戒', '学处', '犯罪', '无犯', '违犯条件'],
+            ],
+            [
+                'id'    => 'vinayakamma',
+                'label' => '僧羯磨',
+                'tags'  => ['羯磨',  '结界', '布萨', '自恣', '受具足', '出家', '僧团', '惩罚羯磨'],
+            ],
+            [
+                'id'    => 'monastic-life',
+                'label' => '僧侣生活与器具',
+                'tags'  => ['三衣', '钵', '住处', '精舍', '雨安居', '迦提那衣', '头陀行', '乞食', '日用器具', '净食'],
+            ],
+        ],
+    ],
+
+    [
+        'id'    => 'dhamma',
+        'label' => '法义',
+        'subs'  => [
+            [
+                'id'    => 'basic-doctrine',
+                'label' => '基本教义',
+                'tags'  => ['四圣谛',  '缘起', '十二缘起', '三法印', '五蕴', '三宝', '八正道',],
+            ],
+            [
+                'id'    => 'kamma-samsara',
+                'label' => '业与轮回',
+                'tags'  => ['业', '善业', '不善业', '无记业', '业报', '轮回', '结生', '再生', '三界轮转'],
+            ],
+            [
+                'id'    => 'nibbana',
+                'label' => '涅槃与解脱',
+                'tags'  => ['涅槃', '有余涅槃', '无余涅槃', '解脱', '道', '果', '烦恼断除', '结', '漏'],
+            ],
+            [
+                'id'    => 'citta',
+                'label' => '心路与心类',
+                'tags'  => ['欲界心', '色界心', '无色界心', '出世间心', '善心', '不善心', '无记心', '心路', '五门心路', '意门心路', '速行', '有分',],
+            ],
+            [
+                'id'    => 'cetasika',
+                'label' => '心所法',
+                'tags'  => ['遍一切心心所', '杂心所', '不善心所', '美心所', '贪', '嗔', '痴', '慢', '邪见', '掉举', '信', '念', '慧', '悲', '喜', '舍'],
+            ],
+            [
+                'id'    => 'rupa',
+                'label' => '色法',
+                'tags'  => ['四大种', '地界', '水界', '火界', '风界', '净色', '所造色', '色聚', '业生色', '心生色', '时节生色', '食生色', '真实色', '非真实色'],
+            ],
+            [
+                'id'    => 'paccaya',
+                'label' => '缘起与发趣法',
+                'tags'  => ['二十四缘', '因缘',],
+            ],
+        ],
+    ],
+
+
+    [
+        'id'    => 'bhavana',
+        'label' => '禅修',
+        'subs'  => [
+            [
+                'id'    => 'samatha',
+                'label' => '止禅',
+                'tags'  => ['遍禅', '不净观', '随念', '四梵住',  '入出息念', '四界差别', '禅相', '取相', '似相', '近行定', '安止定', '禅那', '禅支', '无色定'],
+            ],
+            [
+                'id'    => 'vipassana',
+                'label' => '观禅',
+                'tags'  => ['名色分别', '观智', '生灭智', '坏灭智', '怖畏智', '厌离智', '行舍智', '道智', '果智', '毘婆舍那', '三相', '无常随观', '苦随观', '无我随观', '刹那定'],
+            ],
+        ],
+    ],
+
+
+    [
+        'id'    => 'patha',
+        'label' => '典籍',
+        'tags'  => ['经名', '品名', '篇名'],
+        'subs'  => [
+            [
+                'id'    => 'suttapitaka',
+                'label' => '经藏',
+                'tags'  => ['长部', '中部', '相应部', '增支部', '小部',],
+            ],
+            [
+                'id'    => 'abhidhammapitaka',
+                'label' => '论藏',
+                'tags'  => ['法集论', '分别论', '界论', '人施设论', '论事', '双论'],
+            ],
+            [
+                'id'    => 'vinayapitaka',
+                'label' => '律藏',
+                'tags'  => ['经分别', '篇章', '附随'],
+            ],
+            [
+                'id'    => 'vanna',
+                'label' => '注释',
+                'tags'  => ['义注', '复注', '根本复注', '再复注'],
+            ],
+            [
+                'id'    => 'anna',
+                'label' => '藏外',
+                'tags'  => ['清净道论', '历史', '文法书'],
+            ],
+            [
+                'id'    => 'vatthu',
+                'label' => '故事类',
+                'tags'  => ['本生', '佛种姓', '譬喻', '天宫事', '饿鬼事'],
+            ],
+        ],
+    ],
+
+    [
+        'id'    => 'cosmology',
+        'label' => '世界观',
+        'subs'  => [
+            [
+                'id'    => 'akasaloka',
+                'label' => '空间世间',
+                'tags'  => ['欲界', '色界', '无色界', '欲界天', '梵天界', '净居天', '人间', '有情居', '天界层次'],
+            ],
+            [
+                'id'    => 'apaya',
+                'label' => '地狱与恶趣',
+                'tags'  => ['地狱',  '饿鬼界', '畜生界', '阿修罗界', '四恶趣'],
+            ],
+            [
+                'id'    => 'amanussa',
+                'label' => '神灵与非人',
+                'tags'  => ['天神', '梵天', '夜叉', '龙族', '乾达婆', '非人', '护法神'],
+            ],
+        ],
+    ],
+
+    [
+        'id'    => 'puggala',
+        'label' => '人名',
+        'subs'  => [
+            [
+                'id'    => 'buddha',
+                'label' => '佛',
+                'tags'  => ['佛名', '过去佛', '二十八佛', '独觉佛', '菩萨'],
+            ],
+            [
+                'id'    => 'pabbajita',
+                'label' => '出家弟子',
+                'tags'  => ['上首弟子', '大弟子', '比库弟子', '比库尼弟子', '沙弥', '沙弥尼', '在学尼'],
+            ],
+            [
+                'id'    => 'gahapati',
+                'label' => '在家人',
+                'tags'  => ['男居士', '女居士', '护法者', '国王', '婆罗门', '施主', '转轮圣王', '王后', '大长者'],
+            ],
+            [
+                'id'    => 'titthiya',
+                'label' => '外道',
+                'tags'  => ['外道名'],
+            ],
+        ],
+    ],
+
+    [
+        'id'    => 'padesa',
+        'label' => '地名',
+        'subs'  => [
+            [
+                'id'    => 'gama',
+                'label' => '人类聚落',
+                'tags'  => ['十六大国', '国家', '村落', '市镇', '寺院名'],
+            ],
+            [
+                'id'    => 'udaka',
+                'label' => '水系',
+                'tags'  => ['河流', '湖泊', '海洋'],
+            ],
+            [
+                'id'    => 'pabbata',
+                'label' => '山岳',
+                'tags'  => ['山名', '山脉'],
+            ],
+        ],
+    ],
+
+    [
+        'id'    => 'bhuta',
+        'label' => '动植物',
+        'subs'  => [
+            [
+                'id'    => 'tiracchana',
+                'label' => '动物',
+                'tags'  => ['兽类', '鸟类', '爬行类', '水生动物', '昆虫', '神话动物', '龙族', '金翅鸟', '畜养动物', '野生动物'],
+            ],
+            [
+                'id'    => 'bhutagama',
+                'label' => '植物',
+                'tags'  => ['树木', '花卉', '草药', '粮食作物', '果实', '菩提树类'],
+            ],
+        ],
+    ],
+
+    [
+        'id'    => 'saddaniti',
+        'label' => '巴利语言与语法',
+        'subs'  => [
+            [
+                'id'    => 'grammar-terms',
+                'label' => '语法术语',
+                'tags'  => ['名词', '动词', '形容词', '副词', '格', '数', '性', '时态', '语式', '复合词类型', '前缀', '后缀'],
+            ],
+            [
+                'id'    => 'grammar-abbrev',
+                'label' => '语法缩写与标注',
+                'tags'  => ['词性标注', '格标注', '语态标注', '使役态', '引用标记', '出处标注'],
+            ],
+        ],
+    ],
+
+];

+ 105 - 0
api-v12/documents/category.md

@@ -0,0 +1,105 @@
+**一、经藏教义**
+
+-   1.1 基本教义
+    -   `苦` `集` `灭` `道` `缘起` `十二缘起` `三法印` `无常` `无我` `五蕴` `三宝` `八正道` `中道` `善法` `不善法`
+-   1.2 业与轮回
+    -   `业` `善业` `不善业` `无记业` `业报` `轮回` `结生` `再生` `三界轮转`
+-   1.3 涅槃与解脱
+    -   `涅槃` `有余涅槃` `无余涅槃` `解脱` `道` `果` `烦恼断除` `结` `漏`
+
+---
+
+**二、阿毗达摩**
+
+-   2.1 心路与心类
+    -   `欲界心` `色界心` `无色界心` `出世间心` `善心` `不善心` `无记心` `心路` `五门心路` `意门心路` `速行` `有分` `结生心` `死心` `转向心`
+-   2.2 心所法
+    -   `遍一切心心所` `杂心所` `不善心所` `美心所` `贪` `嗔` `痴` `慢` `邪见` `掉举` `信` `念` `慧` `悲` `喜` `舍`
+-   2.3 色法
+    -   `四大种` `地界` `水界` `火界` `风界` `净色` `所造色` `色聚` `业生色` `心生色` `时节生色` `食生色` `真实色` `非真实色`
+-   2.4 缘起与发趣法
+    -   `二十四缘` `因缘` `所缘缘` `增上缘` `俱生缘` `亲依止缘` `前生缘` `后生缘` `业缘` `果报缘` `根缘` `禅缘` `道缘` `相应缘` `不相应缘` `有缘` `无有缘`
+
+---
+
+**三、禅修**
+
+-   3.1 止禅
+    -   `遍禅` `不净观` `随念` `四梵住` `慈` `悲` `喜` `舍` `入出息念` `四界差别` `禅相` `取相` `似相` `近行定` `安止定` `禅那` `禅支` `无色定`
+-   3.2 观禅
+    -   `名色分别` `观智` `生灭智` `坏灭智` `怖畏智` `厌离智` `行舍智` `道智` `果智` `毘婆舍那` `三相` `无常随观` `苦随观` `无我随观` `刹那定`
+
+---
+
+**四、律学**
+
+-   4.1 戒条罪类
+    -   `波罗夷` `僧残` `不定` `舍堕` `单堕` `悔过` `众学` `灭诤` `比库戒` `比库尼戒` `学处` `犯罪` `无犯` `违犯条件`
+-   4.2 僧团制度与羯磨
+    -   `羯磨` `白羯磨` `白二羯磨` `白四羯磨` `结界` `布萨` `自恣` `受具足` `出家` `僧团` `四方僧` `惩罚羯磨` `和合`
+-   4.3 僧侣生活与器具
+    -   `三衣` `钵` `住处` `精舍` `雨安居` `迦提那衣` `头陀行` `乞食` `日用器具` `净食`
+
+---
+
+**五、经典与文献**
+
+-   5.1 经藏
+    -   `长部` `中部` `相应部` `增支部` `小部` `经名` `品名` `篇名`
+-   5.2 论藏与注疏
+    -   `论藏` `七论` `义注` `复注` `清净道论` `摄论` `史书` `藏外文献`
+-   5.3 本生与偈颂
+    -   `本生` `长老偈` `长老尼偈` `佛种姓` `譬喻` `天宫事` `饿鬼事`
+
+---
+
+**六、世界观**
+
+-   6.1 三界与诸天
+    -   `欲界` `色界` `无色界` `欲界天` `梵天界` `净居天` `人间` `有情居` `天界层次`
+-   6.2 地狱与恶趣
+    -   `地狱` `无间地狱` `寒冰地狱` `孤独地狱` `饿鬼界` `畜生界` `阿修罗界` `四恶趣`
+-   6.3 神灵与非人
+    -   `天神` `梵天` `夜叉` `龙族` `乾达婆` `非人` `护法神`
+
+---
+
+**七、人物**
+
+-   7.1 佛
+    -   `佛名` `过去佛` `二十八佛` `独觉佛` `菩萨`
+-   7.2 出家弟子
+    -   `上首弟子` `大弟子` `比库弟子` `比库尼弟子` `沙弥` `沙弥尼` `在学尼`
+-   7.3 在家人
+    -   `男居士` `女居士` `护法者` `国王` `婆罗门` `施主` `转轮圣王` `王后` `大长者`
+-   7.4 外道
+    -   `外道名`
+
+---
+
+**八、地理**
+
+-   8.1 国家与城镇
+    -   `十六大国` `国家` `村落` `市镇` `寺院名`
+-   8.2 水系
+    -   `河流` `湖泊` `海洋`
+-   8.3 山岳
+    -   `山名` `山脉`
+
+---
+
+**九、动植物**
+
+-   9.1 动物
+    -   `兽类` `鸟类` `爬行类` `鱼类` `昆虫` `神话动物` `龙族` `金翅鸟` `畜养动物` `野生动物`
+-   9.2 植物
+    -   `树木` `花卉` `草药` `粮食作物` `果实` `圣树` `菩提树类`
+
+---
+
+**十、巴利语言与语法**
+
+-   10.1 语法术语
+    -   `名词` `动词` `形容词` `副词` `格` `数` `性` `时态` `语式` `复合词类型` `前缀` `后缀`
+-   10.2 语法缩写与标注
+    -   `词性标注` `格标注` `语态标注` `使役态` `引用标记` `出处标注`

+ 61 - 0
api-v12/documents/opensearch.md

@@ -0,0 +1,61 @@
+### 查看所有 Index
+
+```bash
+curl -X GET "http://127.0.0.1:9200/_cat/indices?v"
+```
+
+### 查看指定 Index
+
+```bash
+curl -X GET "http://127.0.0.1:9200/your_index"
+```
+
+### 删除 Index
+
+```bash
+curl -X DELETE "http://127.0.0.1:9200/your_index"
+```
+
+### 查看某 ID 数据
+
+```bash
+curl -X GET "http://127.0.0.1:9200/your_index/_doc/your_id"
+```
+
+### 删除某 ID 数据
+
+```bash
+curl -X DELETE "http://127.0.0.1:9200/your_index/_doc/your_id"
+```
+
+### 测试 Analyzer(默认)
+
+```bash
+curl -X POST "http://127.0.0.1:9200/_analyze" \
+-H "Content-Type: application/json" \
+-d '{
+  "text": "your text here"
+}'
+```
+
+### 测试 Analyzer(指定 analyzer)
+
+```bash
+curl -X POST "http://127.0.0.1:9200/_analyze" \
+-H "Content-Type: application/json" \
+-d '{
+  "analyzer": "standard",
+  "text": "your text here"
+}'
+```
+
+### 测试 Analyzer(指定 index)
+
+```bash
+curl -X POST "http://127.0.0.1:9200/your_index/_analyze" \
+-H "Content-Type: application/json" \
+-d '{
+  "field": "your_field",
+  "text": "your text here"
+}'
+```

+ 9 - 0
api-v12/resources/css/components/_search-input.css

@@ -11,3 +11,12 @@
     max-width: 500px;
     max-width: 500px;
     margin: 0 auto;
     margin: 0 auto;
 }
 }
+
+.search-suggest-dropdown {
+    max-height: 300px;
+    overflow-y: auto;
+}
+
+.search-suggest-dropdown .dropdown-item {
+    cursor: pointer;
+}

+ 42 - 0
api-v12/resources/css/modules/_tipitaka.css

@@ -353,3 +353,45 @@
         width: 100%;
         width: 100%;
     }
     }
 }
 }
+
+/* 目录项布局 */
+.toc-item {
+    display: flex;
+    flex-direction: column; /* 默认:手机上下 */
+    gap: 0.4rem;
+}
+
+/* 标题 */
+.toc-title {
+    font-size: 0.95rem;
+}
+
+/* 进度区 */
+.toc-progress {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+
+/* 百分比 */
+.toc-progress-text {
+    font-size: 0.7rem;
+    color: var(--tblr-secondary);
+    white-space: nowrap;
+}
+
+/* ≥ md(平板/桌面):左右分布 */
+@media (min-width: 768px) {
+    .toc-item {
+        flex-direction: row;
+        align-items: center;
+    }
+
+    .toc-title {
+        flex: 1;
+    }
+
+    .toc-progress {
+        width: 140px;
+    }
+}

+ 505 - 66
api-v12/resources/css/modules/_wiki.css

@@ -46,7 +46,9 @@
     font-size: 0.8125rem;
     font-size: 0.8125rem;
 }
 }
 
 
-.wiki-entry-lang-label { color: var(--tblr-secondary); }
+.wiki-entry-lang-label {
+    color: var(--tblr-secondary);
+}
 
 
 .wiki-entry-lang-btn {
 .wiki-entry-lang-btn {
     font-size: 0.75rem;
     font-size: 0.75rem;
@@ -58,7 +60,9 @@
     transition: background 0.12s;
     transition: background 0.12s;
 }
 }
 
 
-.wiki-entry-lang-btn:hover { background: var(--tblr-bg-surface); }
+.wiki-entry-lang-btn:hover {
+    background: var(--tblr-bg-surface);
+}
 
 
 /* ══════════════════════════════════════════
 /* ══════════════════════════════════════════
    二、质量标签
    二、质量标签
@@ -82,14 +86,41 @@
     flex-shrink: 0;
     flex-shrink: 0;
 }
 }
 
 
-.wiki-badge--featured { background: #eaf3de; color: #3b6d11; border: 1px solid #c0dd97; }
-.wiki-badge--featured .wiki-quality-dot { background: #639922; }
+.wiki-badge--featured {
+    background: #eaf3de;
+    color: #3b6d11;
+    border: 1px solid #c0dd97;
+}
+.wiki-badge--featured .wiki-quality-dot {
+    background: #639922;
+}
+
+.wiki-badge--standard {
+    background: rgba(var(--tblr-primary-rgb), 0.12);
+    color: var(--tblr-primary);
+    border: 1px solid rgba(var(--tblr-primary-rgb), 0.32);
+}
+.wiki-badge--standard .wiki-quality-dot {
+    background: var(--tblr-primary);
+}
 
 
-.wiki-badge--review   { background: #faeeda; color: #854f0b; border: 1px solid #fac775; }
-.wiki-badge--review   .wiki-quality-dot { background: #ba7517; }
+.wiki-badge--draft {
+    background: rgba(var(--tblr-orange-rgb), 0.12);
+    color: var(--tblr-orange);
+    border: 1px solid rgba(var(--tblr-orange-rgb), 0.32);
+}
+.wiki-badge--draft .wiki-quality-dot {
+    background: var(--tblr-orange);
+}
 
 
-.wiki-badge--stub     { background: #f1efe8; color: #5f5e5a; border: 1px solid #d3d1c7; }
-.wiki-badge--stub     .wiki-quality-dot { background: #888780; }
+.wiki-badge--pending {
+    background: rgba(var(--tblr-secondary-rgb), 0.12);
+    color: var(--tblr-secondary);
+    border: 1px solid rgba(var(--tblr-secondary-rgb), 0.32);
+}
+.wiki-badge--pending .wiki-quality-dot {
+    background: var(--tblr-secondary);
+}
 
 
 /* ══════════════════════════════════════════
 /* ══════════════════════════════════════════
    三、标签
    三、标签
@@ -198,8 +229,12 @@
     box-shadow: none;
     box-shadow: none;
 }
 }
 
 
-.wiki-term-popover .popover-arrow { display: none; }
-.wiki-term-popover .popover-body  { padding: 0; }
+.wiki-term-popover .popover-arrow {
+    display: none;
+}
+.wiki-term-popover .popover-body {
+    padding: 0;
+}
 
 
 .wiki-popover-word {
 .wiki-popover-word {
     font-family: "Noto Serif", Georgia, serif;
     font-family: "Noto Serif", Georgia, serif;
@@ -301,13 +336,27 @@
     border-radius: 4px;
     border-radius: 4px;
 }
 }
 
 
-.wiki-term-skeleton-word { height: 18px; width: 120px; margin-bottom: 8px; }
-.wiki-term-skeleton-line { height: 13px; width: 100%; margin-bottom: 6px; }
-.wiki-term-skeleton-line.short { width: 60%; }
+.wiki-term-skeleton-word {
+    height: 18px;
+    width: 120px;
+    margin-bottom: 8px;
+}
+.wiki-term-skeleton-line {
+    height: 13px;
+    width: 100%;
+    margin-bottom: 6px;
+}
+.wiki-term-skeleton-line.short {
+    width: 60%;
+}
 
 
 @keyframes wiki-skeleton-shimmer {
 @keyframes wiki-skeleton-shimmer {
-    0%   { background-position: 200% 0; }
-    100% { background-position: -200% 0; }
+    0% {
+        background-position: 200% 0;
+    }
+    100% {
+        background-position: -200% 0;
+    }
 }
 }
 
 
 /* ══════════════════════════════════════════
 /* ══════════════════════════════════════════
@@ -323,7 +372,10 @@
     overflow-wrap: break-word;
     overflow-wrap: break-word;
 }
 }
 
 
-.wiki-content-body p { margin-top: 0; margin-bottom: 1.125em; }
+.wiki-content-body p {
+    margin-top: 0;
+    margin-bottom: 1.125em;
+}
 
 
 .wiki-content-body h1,
 .wiki-content-body h1,
 .wiki-content-body h2,
 .wiki-content-body h2,
@@ -340,13 +392,33 @@
     scroll-margin-top: 80px;
     scroll-margin-top: 80px;
 }
 }
 
 
-.wiki-content-body h1 { font-size: 1.375rem; padding-bottom: .4em; border-bottom: 2px solid var(--tblr-border-color); }
-.wiki-content-body h2 { font-size: 1.25rem;  padding-bottom: .4em; border-bottom: 1px solid var(--tblr-border-color); }
-.wiki-content-body h3 { font-size: 1.0625rem; }
-.wiki-content-body h4 { font-size: 0.9375rem; font-weight: 600; color: var(--tblr-secondary); }
+.wiki-content-body h1 {
+    font-size: 1.375rem;
+    padding-bottom: 0.4em;
+    border-bottom: 2px solid var(--tblr-border-color);
+}
+.wiki-content-body h2 {
+    font-size: 1.25rem;
+    padding-bottom: 0.4em;
+    border-bottom: 1px solid var(--tblr-border-color);
+}
+.wiki-content-body h3 {
+    font-size: 1.0625rem;
+}
+.wiki-content-body h4 {
+    font-size: 0.9375rem;
+    font-weight: 600;
+    color: var(--tblr-secondary);
+}
 
 
-.wiki-content-body strong, .wiki-content-body b { font-weight: 600; }
-.wiki-content-body em,     .wiki-content-body i { font-style: italic; }
+.wiki-content-body strong,
+.wiki-content-body b {
+    font-weight: 600;
+}
+.wiki-content-body em,
+.wiki-content-body i {
+    font-style: italic;
+}
 
 
 .wiki-content-body a {
 .wiki-content-body a {
     color: var(--tblr-primary);
     color: var(--tblr-primary);
@@ -354,7 +426,9 @@
     border-bottom: 1px solid transparent;
     border-bottom: 1px solid transparent;
     transition: border-color 0.12s;
     transition: border-color 0.12s;
 }
 }
-.wiki-content-body a:hover { border-bottom-color: var(--tblr-primary); }
+.wiki-content-body a:hover {
+    border-bottom-color: var(--tblr-primary);
+}
 
 
 .wiki-content-body blockquote {
 .wiki-content-body blockquote {
     margin: 1.5em 0;
     margin: 1.5em 0;
@@ -366,46 +440,92 @@
     line-height: 1.75;
     line-height: 1.75;
 }
 }
 
 
-.wiki-content-body blockquote p          { margin-bottom: 0.5em; }
-.wiki-content-body blockquote p:last-child { margin-bottom: 0; }
+.wiki-content-body blockquote p {
+    margin-bottom: 0.5em;
+}
+.wiki-content-body blockquote p:last-child {
+    margin-bottom: 0;
+}
 .wiki-content-body blockquote cite {
 .wiki-content-body blockquote cite {
     display: block;
     display: block;
-    margin-top: .625rem;
-    font-size: .8125rem;
+    margin-top: 0.625rem;
+    font-size: 0.8125rem;
     font-style: normal;
     font-style: normal;
     color: var(--tblr-secondary);
     color: var(--tblr-secondary);
 }
 }
 
 
 .wiki-content-body ul,
 .wiki-content-body ul,
-.wiki-content-body ol { padding-left: 1.5em; margin-bottom: 1.125em; }
-.wiki-content-body li { margin-bottom: .375em; line-height: 1.75; }
+.wiki-content-body ol {
+    padding-left: 1.5em;
+    margin-bottom: 1.125em;
+}
+.wiki-content-body li {
+    margin-bottom: 0.375em;
+    line-height: 1.75;
+}
 
 
-.wiki-content-body table { width: 100%; border-collapse: collapse; font-size: .9rem; margin-bottom: 1.5em; }
+.wiki-content-body table {
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 0.9rem;
+    margin-bottom: 1.5em;
+}
 .wiki-content-body th {
 .wiki-content-body th {
     font-family: "Noto Serif", Georgia, serif;
     font-family: "Noto Serif", Georgia, serif;
-    font-weight: 600; font-size: .8125rem; text-align: left;
+    font-weight: 600;
+    font-size: 0.8125rem;
+    text-align: left;
     padding: 8px 12px;
     padding: 8px 12px;
     background: var(--tblr-bg-surface-secondary);
     background: var(--tblr-bg-surface-secondary);
     border-bottom: 2px solid var(--tblr-border-color);
     border-bottom: 2px solid var(--tblr-border-color);
     color: var(--tblr-secondary);
     color: var(--tblr-secondary);
-    text-transform: uppercase; letter-spacing: .04em;
+    text-transform: uppercase;
+    letter-spacing: 0.04em;
+}
+.wiki-content-body td {
+    padding: 8px 12px;
+    border-bottom: 1px solid var(--tblr-border-color);
+    vertical-align: top;
+}
+.wiki-content-body tr:last-child td {
+    border-bottom: none;
 }
 }
-.wiki-content-body td { padding: 8px 12px; border-bottom: 1px solid var(--tblr-border-color); vertical-align: top; }
-.wiki-content-body tr:last-child td { border-bottom: none; }
 
 
 .wiki-content-body code {
 .wiki-content-body code {
-    font-family: var(--tblr-font-monospace, "SFMono-Regular", Consolas, monospace);
-    font-size: .875em;
+    font-family: var(
+        --tblr-font-monospace,
+        "SFMono-Regular",
+        Consolas,
+        monospace
+    );
+    font-size: 0.875em;
     background: var(--tblr-bg-surface-secondary);
     background: var(--tblr-bg-surface-secondary);
     border: 1px solid var(--tblr-border-color);
     border: 1px solid var(--tblr-border-color);
     border-radius: 4px;
     border-radius: 4px;
     padding: 1px 5px;
     padding: 1px 5px;
 }
 }
 
 
-.wiki-content-body hr  { border: none; border-top: 1px solid var(--tblr-border-color); margin: 2em 0; }
-.wiki-content-body img { max-width: 100%; height: auto; border-radius: var(--tblr-border-radius); margin: .75em 0; }
-.wiki-content-body figure { margin: 1.5em 0; text-align: center; }
-.wiki-content-body figcaption { font-size: .8125rem; color: var(--tblr-secondary); margin-top: .5em; font-style: italic; }
+.wiki-content-body hr {
+    border: none;
+    border-top: 1px solid var(--tblr-border-color);
+    margin: 2em 0;
+}
+.wiki-content-body img {
+    max-width: 100%;
+    height: auto;
+    border-radius: var(--tblr-border-radius);
+    margin: 0.75em 0;
+}
+.wiki-content-body figure {
+    margin: 1.5em 0;
+    text-align: center;
+}
+.wiki-content-body figcaption {
+    font-size: 0.8125rem;
+    color: var(--tblr-secondary);
+    margin-top: 0.5em;
+    font-style: italic;
+}
 
 
 /* ══════════════════════════════════════════
 /* ══════════════════════════════════════════
    九、Wiki 首页
    九、Wiki 首页
@@ -420,31 +540,53 @@
     padding: 3rem 1.5rem;
     padding: 3rem 1.5rem;
 }
 }
 
 
-.wiki-home-wheel        { margin-bottom: 1.5rem; }
-.wiki-home-wheel-img    { width: 120px; height: auto; opacity: .85; transition: transform .3s ease; }
-.wiki-home-wheel-img:hover { transform: scale(1.05); }
+.wiki-home-wheel {
+    margin-bottom: 1.5rem;
+}
+.wiki-home-wheel-img {
+    width: 120px;
+    height: auto;
+    opacity: 0.85;
+    transition: transform 0.3s ease;
+}
+.wiki-home-wheel-img:hover {
+    transform: scale(1.05);
+}
 
 
-.wiki-home-title        { text-align: center; margin-bottom: 2rem; }
+.wiki-home-title {
+    text-align: center;
+    margin-bottom: 2rem;
+}
 .wiki-home-title h1 {
 .wiki-home-title h1 {
     font-family: "Noto Serif", Georgia, serif;
     font-family: "Noto Serif", Georgia, serif;
-    font-size: 2.25rem; font-weight: 600;
-    color: var(--tblr-body-color); margin-bottom: .5rem;
+    font-size: 2.25rem;
+    font-weight: 600;
+    color: var(--tblr-body-color);
+    margin-bottom: 0.5rem;
 }
 }
 
 
-.wiki-home-search       { width: 100%; max-width: 640px; margin: 0 auto 1.5rem; }
+.wiki-home-search {
+    width: 100%;
+    max-width: 640px;
+    margin: 0 auto 1.5rem;
+}
 
 
-.wiki-home-hot-tags     { text-align: center; margin-bottom: 2.5rem; font-size: .9rem; }
+.wiki-home-hot-tags {
+    text-align: center;
+    margin-bottom: 2.5rem;
+    font-size: 0.9rem;
+}
 .wiki-hot-tag {
 .wiki-hot-tag {
     display: inline-block;
     display: inline-block;
-    padding: .3rem .75rem;
+    padding: 0.3rem 0.75rem;
     background: var(--tblr-bg-surface-secondary);
     background: var(--tblr-bg-surface-secondary);
     border: 1px solid var(--tblr-border-color);
     border: 1px solid var(--tblr-border-color);
     border-radius: 20px;
     border-radius: 20px;
     color: var(--tblr-secondary);
     color: var(--tblr-secondary);
-    font-size: .8125rem;
+    font-size: 0.8125rem;
     text-decoration: none;
     text-decoration: none;
     margin: 2px;
     margin: 2px;
-    transition: background .12s, color .12s;
+    transition: background 0.12s, color 0.12s;
 }
 }
 .wiki-hot-tag:hover {
 .wiki-hot-tag:hover {
     background: var(--wp-brand-light);
     background: var(--wp-brand-light);
@@ -452,26 +594,43 @@
     color: var(--wp-brand-dark);
     color: var(--wp-brand-dark);
 }
 }
 
 
-.wiki-home-languages    { width: 100%; max-width: 800px; margin: 0 auto 2rem; }
+.wiki-home-languages {
+    width: 100%;
+    max-width: 800px;
+    margin: 0 auto 2rem;
+}
 .wiki-home-divider {
 .wiki-home-divider {
-    display: flex; align-items: center; text-align: center;
-    margin-bottom: 1.5rem; gap: 1.5rem;
-    color: var(--tblr-secondary); font-size: .875rem;
+    display: flex;
+    align-items: center;
+    text-align: center;
+    margin-bottom: 1.5rem;
+    gap: 1.5rem;
+    color: var(--tblr-secondary);
+    font-size: 0.875rem;
 }
 }
 .wiki-home-divider::before,
 .wiki-home-divider::before,
-.wiki-home-divider::after { content: ''; flex: 1; border-bottom: 1px solid var(--tblr-border-color); }
+.wiki-home-divider::after {
+    content: "";
+    flex: 1;
+    border-bottom: 1px solid var(--tblr-border-color);
+}
 
 
-.wiki-language-tags     { display: flex; flex-wrap: wrap; justify-content: center; gap: .75rem; }
+.wiki-language-tags {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: center;
+    gap: 0.75rem;
+}
 .wiki-language-tag {
 .wiki-language-tag {
     display: inline-block;
     display: inline-block;
-    padding: .4rem 1.125rem;
+    padding: 0.4rem 1.125rem;
     background: var(--tblr-bg-surface-secondary);
     background: var(--tblr-bg-surface-secondary);
     border: 1px solid var(--tblr-border-color);
     border: 1px solid var(--tblr-border-color);
     border-radius: 30px;
     border-radius: 30px;
     color: var(--tblr-body-color);
     color: var(--tblr-body-color);
-    font-size: .875rem;
+    font-size: 0.875rem;
     text-decoration: none;
     text-decoration: none;
-    transition: background .12s, border-color .12s;
+    transition: background 0.12s, border-color 0.12s;
 }
 }
 .wiki-language-tag:hover {
 .wiki-language-tag:hover {
     background: var(--wp-brand-light);
     background: var(--wp-brand-light);
@@ -489,15 +648,295 @@
 }
 }
 
 
 .wiki-home-stats {
 .wiki-home-stats {
-    text-align: center; font-size: .875rem;
+    text-align: center;
+    font-size: 0.875rem;
     padding-top: 1.5rem;
     padding-top: 1.5rem;
     border-top: 1px solid var(--tblr-border-color);
     border-top: 1px solid var(--tblr-border-color);
-    width: 100%; max-width: 640px;
+    width: 100%;
+    max-width: 640px;
 }
 }
 
 
 @media (max-width: 768px) {
 @media (max-width: 768px) {
-    .wiki-home-container    { min-height: calc(100vh - 200px); padding: 2rem 1rem; }
-    .wiki-home-wheel-img    { width: 90px; }
-    .wiki-home-title h1     { font-size: 1.75rem; }
-    .wiki-language-tag      { padding: .35rem .875rem; font-size: .8125rem; }
+    .wiki-home-container {
+        min-height: calc(100vh - 200px);
+        padding: 2rem 1rem;
+    }
+    .wiki-home-wheel-img {
+        width: 90px;
+    }
+    .wiki-home-title h1 {
+        font-size: 1.75rem;
+    }
+    .wiki-language-tag {
+        padding: 0.35rem 0.875rem;
+        font-size: 0.8125rem;
+    }
+}
+
+/* ── 追加到 resources/css/wiki.css 末尾 ── */
+
+/* ── 二级分类大块容器 ── */
+.wiki-subcat-block {
+    padding: 0;
+}
+
+.wiki-subcat-block-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 1rem 1.5rem 0.875rem;
+    border-bottom: 1px solid var(--tblr-border-color);
+}
+
+.wiki-subcat-block-title {
+    font-size: 0.9375rem;
+    font-weight: 600;
+    color: var(--tblr-body-color);
+}
+
+.wiki-subcat-block-more {
+    font-size: 0.8125rem;
+    color: var(--tblr-primary);
+    text-decoration: none;
+}
+
+.wiki-subcat-block-more:hover {
+    text-decoration: underline;
+}
+
+/* ── 单个二级分类 ── */
+.wiki-subcat {
+    padding: 1rem 1.5rem;
+    border-bottom: 1px solid var(--tblr-border-color);
+}
+
+.wiki-subcat:last-child {
+    border-bottom: none;
+}
+
+.wiki-subcat-header {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 0.625rem;
+}
+
+.wiki-subcat-title {
+    font-size: 0.8125rem;
+    font-weight: 500;
+    color: var(--tblr-secondary);
+    text-transform: uppercase;
+    letter-spacing: 0.04em;
+}
+
+.wiki-subcat-count {
+    font-size: 0.6875rem;
+    color: var(--tblr-secondary);
+    background: var(--tblr-bg-surface-secondary);
+    border: 1px solid var(--tblr-border-color);
+    border-radius: 20px;
+    padding: 1px 7px;
+}
+
+/* ── 词条列表 (flex wrap) ── */
+.wiki-subcat-entries {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px 0;
+}
+
+/* ── 单个术语链接 ── */
+.wiki-term-link {
+    display: inline-flex;
+    align-items: baseline;
+    gap: 4px;
+    padding: 3px 8px;
+    border-radius: var(--tblr-border-radius);
+    text-decoration: none;
+    font-size: 0.875rem;
+    transition: background 0.1s;
+    color: var(--tblr-primary);
+}
+
+.wiki-term-link:hover {
+    background: var(--tblr-bg-surface-secondary);
+    text-decoration: none;
+    color: var(--tblr-primary);
+}
+
+.wiki-term-link-word {
+    font-style: italic;
+    color: var(--tblr-secondary);
+}
+
+.wiki-term-link-zh {
+    font-size: 0.8125rem;
+}
+
+/* =========================
+   Wiki Term Link States
+   典范条目 featured(蓝色 + 星标)
+   规范条目 standard(蓝色)
+   草稿 draft(橙色)
+   待定 pending(灰色 + 删除线)
+   ========================= */
+
+/* 通用链接颜色继承 */
+.wiki-term-link--featured,
+.wiki-term-link--standard,
+.wiki-term-link--draft,
+.wiki-term-link--pending {
+    position: relative;
+}
+
+.wiki-term-link--featured:hover,
+.wiki-term-link--standard:hover,
+.wiki-term-link--draft:hover,
+.wiki-term-link--pending:hover {
+    color: inherit;
+}
+
+/* =========================
+   典范条目 Featured
+   ========================= */
+.wiki-term-link--featured {
+    color: var(--tblr-green);
+}
+
+.wiki-term-link--featured::before {
+    content: "★";
+    display: inline-block;
+    margin-right: 0.2em;
+    font-size: 0.75em;
+    vertical-align: middle;
+    opacity: 0.9;
+}
+
+/* 若想改圆点可用:
+content: "●";
+*/
+
+/* =========================
+   规范条目 Standard
+   ========================= */
+.wiki-term-link--standard {
+    color: var(--tblr-primary);
+}
+
+/* =========================
+   草稿 Draft
+   ========================= */
+.wiki-term-link--draft {
+    color: var(--tblr-orange);
+}
+
+/* =========================
+   待定 Pending
+   (保持原有效果)
+   ========================= */
+.wiki-term-link--pending {
+    color: var(--tblr-secondary);
+}
+
+.wiki-term-link--pending .wiki-term-link-word {
+    text-decoration: line-through;
+    text-decoration-color: var(--tblr-border-color-dark, #adb5bd);
+}
+
+.wiki-quality-filter-item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 8px;
+}
+
+.wiki-quality-filter-item span:last-child {
+    font-size: 0.75rem;
+    color: var(--tblr-secondary);
+    margin-left: auto;
+    flex-shrink: 0;
+}
+
+/* ── 条目操作按钮 ── */
+.wiki-entry-card {
+    position: relative;
+}
+
+.wiki-entry-actions {
+    position: absolute;
+    top: 1.25rem;
+    right: 1.5rem;
+    display: flex;
+    gap: 4px;
+}
+
+.wiki-action-btn {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 32px;
+    height: 32px;
+    border-radius: var(--tblr-border-radius);
+    border: 1px solid var(--tblr-border-color);
+    background: var(--tblr-bg-surface);
+    color: var(--tblr-secondary);
+    cursor: pointer;
+    text-decoration: none;
+    transition: background 0.12s, color 0.12s;
+}
+
+.wiki-action-btn:hover {
+    background: var(--tblr-bg-surface-secondary);
+    color: var(--tblr-body-color);
+}
+
+/* ── 微信二维码弹窗 ── */
+.wiki-wechat-modal {
+    position: fixed;
+    inset: 0;
+    z-index: 1050;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+.wiki-wechat-modal-backdrop {
+    position: absolute;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.45);
+}
+
+.wiki-wechat-modal-box {
+    position: relative;
+    background: var(--tblr-bg-surface);
+    border-radius: var(--tblr-border-radius-lg);
+    padding: 1.5rem;
+    text-align: center;
+    width: 220px;
+    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+}
+
+.wiki-wechat-modal-title {
+    font-weight: 600;
+    margin-bottom: 4px;
+}
+
+.wiki-wechat-modal-desc {
+    font-size: 0.8125rem;
+    color: var(--tblr-secondary);
+    margin-bottom: 1rem;
+}
+
+.wiki-wechat-qr img {
+    border-radius: var(--tblr-border-radius);
+}
+
+.wiki-wechat-modal-close {
+    margin-top: 1rem;
+    font-size: 0.8125rem;
+    color: var(--tblr-secondary);
+    background: none;
+    border: none;
+    cursor: pointer;
+    text-decoration: underline;
 }
 }

+ 4 - 3
api-v12/resources/js/app.js

@@ -1,7 +1,8 @@
 // resources/js/app.js
 // resources/js/app.js
-import './bootstrap';
-import { initNavbar } from './modules/navbar';
+import "./bootstrap";
+import "./search-suggest";
+import { initNavbar } from "./modules/navbar";
 
 
-document.addEventListener('DOMContentLoaded', () => {
+document.addEventListener("DOMContentLoaded", () => {
     initNavbar();
     initNavbar();
 });
 });

+ 91 - 0
api-v12/resources/js/search-suggest.js

@@ -0,0 +1,91 @@
+document.addEventListener("DOMContentLoaded", () => {
+    const inputs = document.querySelectorAll(".search-input");
+
+    inputs.forEach((input) => {
+        const form = input.closest(".search-input-form");
+        const dropdown = form.querySelector(".search-suggest-dropdown");
+
+        let controller = null;
+
+        input.addEventListener("input", async () => {
+            const q = input.value.trim();
+
+            if (q.length < 2) {
+                dropdown.classList.remove("show");
+                return;
+            }
+
+            // 取消上一次请求(防抖 + 避免竞态)
+            if (controller) controller.abort();
+            controller = new AbortController();
+
+            try {
+                const url = new URL(
+                    input.dataset.suggestUrl,
+                    window.location.origin
+                );
+                url.searchParams.set("q", q);
+                url.searchParams.set("limit", 10);
+
+                const res = await fetch(url, {
+                    signal: controller.signal,
+                });
+
+                const json = await res.json();
+
+                renderSuggestions(json.data.suggestions || []);
+            } catch (e) {
+                if (e.name !== "AbortError") {
+                    console.error(e);
+                }
+            }
+        });
+
+        function renderSuggestions(list) {
+            if (!list.length) {
+                dropdown.classList.remove("show");
+                return;
+            }
+
+            dropdown.innerHTML = list
+                .map((item) => {
+                    return `
+                    <button type="button"
+                        class="dropdown-item"
+                        data-text="${item.text}">
+
+                        <div class="d-flex justify-content-between">
+                            <span>${item.text}</span>
+                            <small class="text-muted">${item.resource_type}</small>
+                        </div>
+                    </button>
+                `;
+                })
+                .join("");
+
+            dropdown.classList.add("show");
+        }
+
+        // 点击选择
+        dropdown.addEventListener("click", (e) => {
+            const btn = e.target.closest(".dropdown-item");
+            if (!btn) return;
+
+            input.value = btn.dataset.text;
+            dropdown.classList.remove("show");
+
+            form.submit(); // 或者只填充不提交
+        });
+
+        // 失焦隐藏
+        input.addEventListener("blur", () => {
+            setTimeout(() => dropdown.classList.remove("show"), 150);
+        });
+
+        input.addEventListener("focus", () => {
+            if (dropdown.innerHTML.trim()) {
+                dropdown.classList.add("show");
+            }
+        });
+    });
+});

+ 31 - 19
api-v12/resources/views/components/ui/search-input.blade.php

@@ -1,14 +1,4 @@
-{{-- resources/views/components/ui/search-input.blade.php
-     通用搜索输入框组件。
-     Props:
-       $action      — 表单提交路由
-       $value       — 当前搜索词(默认空)
-       $placeholder — 占位文字
-       $buttonText  — 按钮文字(默认"搜索")
-       $size        — lg | md(默认 md)
-       $hiddenFields — 额外隐藏字段 array ['name' => 'value']
-       $autofocus   — 是否自动聚焦(默认 false)
---}}
+{{-- resources/views/components/ui/search-input.blade.php --}}
 @props([
 @props([
 'action',
 'action',
 'value' => '',
 'value' => '',
@@ -19,24 +9,46 @@
 'autofocus' => false,
 'autofocus' => false,
 ])
 ])
 
 
-<form action="{{ $action }}" method="GET" class="search-input-form">
+<form action="{{ $action }}" method="GET" class="search-input-form position-relative">
     <div class="input-group {{ $size === 'lg' ? 'input-group-lg' : '' }}">
     <div class="input-group {{ $size === 'lg' ? 'input-group-lg' : '' }}">
-        <input
-            type="text"
-            name="q"
-            class="form-control search-input"
-            value="{{ $value }}"
-            placeholder="{{ $placeholder }}"
-            {{ $autofocus ? 'autofocus' : '' }} />
 
 
+        {{-- 🔍 图标输入框 --}}
+        <div class="input-icon flex-grow-1">
+            <span class="input-icon-addon">
+                {{-- Tabler 搜索图标 --}}
+                <svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
+                    viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
+                    fill="none" stroke-linecap="round" stroke-linejoin="round">
+                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+                    <circle cx="10" cy="10" r="7" />
+                    <line x1="21" y1="21" x2="15" y2="15" />
+                </svg>
+            </span>
+
+            <input
+                type="text"
+                name="q"
+                class="form-control search-input"
+                value="{{ $value }}"
+                placeholder="{{ $placeholder }}"
+                autocomplete="off"
+                data-suggest-url="/api/v3/search-suggest"
+                {{ $autofocus ? 'autofocus' : '' }} />
+        </div>
+
+        {{-- 隐藏字段 --}}
         @foreach ($hiddenFields as $name => $val)
         @foreach ($hiddenFields as $name => $val)
         @if ($val)
         @if ($val)
         <input type="hidden" name="{{ $name }}" value="{{ $val }}">
         <input type="hidden" name="{{ $name }}" value="{{ $val }}">
         @endif
         @endif
         @endforeach
         @endforeach
 
 
+        {{-- 按钮 --}}
         <button class="btn btn-primary" type="submit">
         <button class="btn btn-primary" type="submit">
             {{ $buttonText }}
             {{ $buttonText }}
         </button>
         </button>
     </div>
     </div>
+
+    {{-- 自动补全下拉 --}}
+    <div class="search-suggest-dropdown dropdown-menu w-100"></div>
 </form>
 </form>

+ 95 - 0
api-v12/resources/views/components/wiki/entry-actions.blade.php

@@ -0,0 +1,95 @@
+{{-- resources/views/components/wiki/entry-actions.blade.php --}}
+@props(['editUrl', 'title' => ''])
+
+<div class="wiki-entry-actions">
+
+    {{-- 分享到微信 --}}
+    <button class="wiki-action-btn"
+        id="wikiShareWechat"
+        title="分享到微信"
+        data-title="{{ $title }}">
+        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
+            fill="none" stroke="currentColor" stroke-width="1.5"
+            stroke-linecap="round" stroke-linejoin="round">
+            <path d="M9.5 9a3.5 3.5 0 0 1 5 0" />
+            <path d="M5.5 11.5C4 10 3 8.1 3 6c0-3.3 3.1-6 7-6s7 2.7 7 6c0 .6-.1 1.2-.3 1.7" />
+            <path d="M12 20c-4.4 0-8-2.9-8-6.5S7.6 7 12 7s8 2.9 8 6.5c0 1.4-.5 2.7-1.4 3.8l.4 2.7-2.6-1.1A9 9 0 0 1 12 20z" />
+        </svg>
+    </button>
+
+    {{-- 编辑 --}}
+    <a class="wiki-action-btn"
+        href="{{ $editUrl }}"
+        target="_blank"
+        rel="noopener"
+        title="编辑条目">
+        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
+            fill="none" stroke="currentColor" stroke-width="1.5"
+            stroke-linecap="round" stroke-linejoin="round">
+            <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
+            <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
+        </svg>
+    </a>
+
+</div>
+
+{{-- 微信二维码弹窗 --}}
+<div class="wiki-wechat-modal" id="wikiWechatModal" style="display:none;">
+    <div class="wiki-wechat-modal-backdrop" id="wikiWechatBackdrop"></div>
+    <div class="wiki-wechat-modal-box">
+        <div class="wiki-wechat-modal-title">分享到微信</div>
+        <div class="wiki-wechat-modal-desc">使用微信扫描二维码</div>
+        <div id="wikiWechatQr" class="wiki-wechat-qr"></div>
+        <button class="wiki-wechat-modal-close" id="wikiWechatClose">关闭</button>
+    </div>
+</div>
+
+@push('scripts')
+<script>
+    (function() {
+        const btn = document.getElementById('wikiShareWechat');
+        const modal = document.getElementById('wikiWechatModal');
+        const backdrop = document.getElementById('wikiWechatBackdrop');
+        const closeBtn = document.getElementById('wikiWechatClose');
+        const qrEl = document.getElementById('wikiWechatQr');
+
+        if (!btn || !modal || !qrEl) return;
+
+        function openWechatShare() {
+            const url = encodeURIComponent(window.location.href);
+
+            // 使用更稳定的二维码服务
+            qrEl.innerHTML = `
+            <img
+                src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${url}"
+                width="180"
+                height="180"
+                alt="QR Code"
+            >
+        `;
+
+            modal.style.display = 'flex';
+        }
+
+        function closeWechatShare() {
+            modal.style.display = 'none';
+        }
+
+        btn.addEventListener('click', openWechatShare);
+
+        if (closeBtn) {
+            closeBtn.addEventListener('click', closeWechatShare);
+        }
+
+        if (backdrop) {
+            backdrop.addEventListener('click', closeWechatShare);
+        }
+
+        document.addEventListener('keydown', function(e) {
+            if (e.key === 'Escape') {
+                closeWechatShare();
+            }
+        });
+    })();
+</script>
+@endpush

+ 5 - 14
api-v12/resources/views/components/wiki/quality-badge.blade.php

@@ -1,18 +1,9 @@
 {{-- resources/views/components/wiki/quality-badge.blade.php --}}
 {{-- resources/views/components/wiki/quality-badge.blade.php --}}
 @props(['quality' => null])
 @props(['quality' => null])
 
 
-@php
-    $map = [
-        'featured' => ['label' => '精选条目', 'class' => 'wiki-badge--featured'],
-        'review'   => ['label' => '待审核',   'class' => 'wiki-badge--review'],
-        'stub'     => ['label' => '存根',     'class' => 'wiki-badge--stub'],
-    ];
-    $config = $map[$quality] ?? null;
-@endphp
-
-@if ($config)
-    <span class="wiki-quality-badge {{ $config['class'] }}">
-        <span class="wiki-quality-dot"></span>
-        {{ $config['label'] }}
-    </span>
+@if ($quality)
+<span class="wiki-quality-badge wiki-badge--{{ $quality }}">
+    <span class="wiki-quality-dot"></span>
+    {{ $quality }}
+</span>
 @endif
 @endif

+ 0 - 50
api-v12/resources/views/components/wiki/search-box.blade.php

@@ -1,50 +0,0 @@
-{{-- resources/views/components/wiki/search-box.blade.php (Tabler 增强版) --}}
-@props([
-'action' => '/library/search',
-'method' => 'GET',
-'placeholder' => '搜索佛法词条、经典、人物...',
-'buttonText' => '搜索',
-'queryParam' => 'q',
-'typeParam' => 'type',
-'typeValue' => 'wiki',
-'autoFocus' => false,
-'size' => 'md', // sm, md, lg
-'class' => '',
-])
-
-<div {{ $attributes->merge(['class' => $class]) }}>
-    <form action="{{ $action }}" method="{{ $method }}" role="search">
-        <div class="row g-2 align-items-center">
-            <div class="col">
-                <div class="input-icon">
-                    <span class="input-icon-addon">
-                        <svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
-                            <path stroke="none" d="M0 0h24v24H0z" fill="none" />
-                            <circle cx="10" cy="10" r="7" />
-                            <line x1="21" y1="21" x2="15" y2="15" />
-                        </svg>
-                    </span>
-                    <input
-                        type="text"
-                        name="{{ $queryParam }}"
-                        class="form-control"
-                        placeholder="{{ $placeholder }}"
-                        value="{{ request($queryParam) }}"
-                        {{ $autoFocus ? 'autofocus' : '' }}
-                        aria-label="{{ $placeholder }}">
-                </div>
-            </div>
-            <div class="col-auto">
-                <button type="submit" class="btn btn-primary">
-                    <span class="d-none d-sm-inline ms-2">{{ $buttonText }}</span>
-                </button>
-            </div>
-        </div>
-
-        @if($typeParam && $typeValue)
-        <input type="hidden" name="{{ $typeParam }}" value="{{ $typeValue }}">
-        @endif
-
-        {{ $slot }}
-    </form>
-</div>

+ 10 - 3
api-v12/resources/views/components/wiki/search-result-card.blade.php

@@ -5,9 +5,16 @@
 
 
     <div class="wiki-search-card-header">
     <div class="wiki-search-card-header">
         <a class="wiki-search-card-title"
         <a class="wiki-search-card-title"
-           href="{{ route('library.wiki.show', [$result['lang'], $result['word']]) }}">
-            {{ $result['zh'] }}
-            <span class="wiki-search-card-word">{{ $result['word'] }}</span>
+            href="
+            @if($result['type']==='term')
+            {{ route('library.wiki.show', [$result['lang'], $result['id']]) }}
+            @endif
+            @if($result['type']==='tipitaka')
+            {{ route('library.tipitaka.read', ['id' => $result['chapter']['id'], 'channel' => $result['chapter']['channel']]) }}
+            @endif
+            ">
+            {{ $result['title'] }}
+            <span class="wiki-search-card-word">{{ $result['type'] }}</span>
         </a>
         </a>
         <x-wiki.quality-badge :quality="$result['quality']" />
         <x-wiki.quality-badge :quality="$result['quality']" />
     </div>
     </div>

+ 14 - 0
api-v12/resources/views/components/wiki/sub-category.blade.php

@@ -0,0 +1,14 @@
+{{-- resources/views/components/wiki/sub-category.blade.php --}}
+@props(['sub', 'lang'])
+
+<div class="wiki-subcat">
+    <div class="wiki-subcat-header">
+        <span class="wiki-subcat-title">{{ $sub['label'] }}</span>
+        <span class="wiki-subcat-count">{{ count($sub['entries']) }} 条</span>
+    </div>
+    <div class="wiki-subcat-entries">
+        @foreach ($sub['entries'] as $entry)
+            <x-wiki.term-link :entry="$entry" :lang="$lang" />
+        @endforeach
+    </div>
+</div>

+ 14 - 0
api-v12/resources/views/components/wiki/term-link.blade.php

@@ -0,0 +1,14 @@
+{{-- resources/views/components/wiki/term-link.blade.php --}}
+{{--
+    单个术语链接,两种状态:
+    published → 蓝色链接
+    draft     → 灰色,仍可点击
+--}}
+@props(['entry', 'lang'])
+
+<a href="{{ route('library.wiki.show', [$entry['lang'] ?? $lang, $entry['quality']==='pending'?$entry['id']:$entry['word']]) }}"
+    class="wiki-term-link wiki-term-link--{{ $entry['quality'] }}"
+    title="{{ $entry['quality'] }}">
+    <span class="wiki-term-link-zh">{{ $entry['zh'] ?? $entry['meaning'] ?? '' }}</span>
+    <span class="wiki-term-link-word">{{ $entry['word'] }}</span>
+</a>

+ 9 - 12
api-v12/resources/views/library/anthology/index.blade.php

@@ -4,7 +4,7 @@
 @section('title', '文集 · 巴利书库')
 @section('title', '文集 · 巴利书库')
 
 
 @push('styles')
 @push('styles')
-    @vite('resources/css/modules/_anthology.css')
+@vite('resources/css/modules/_anthology.css')
 @endpush
 @endpush
 
 
 @section('breadcrumb')
 @section('breadcrumb')
@@ -32,14 +32,13 @@
             <div class="col-lg-9">
             <div class="col-lg-9">
 
 
                 @forelse($anthologies as $item)
                 @forelse($anthologies as $item)
-                    <x-ui.card-anthology
-                        :item="$item"
-                        :href="route('library.anthology.show', $item['id'])"
-                    />
+                <x-ui.card-anthology
+                    :item="$item"
+                    :href="route('library.anthology.show', $item['id'])" />
                 @empty
                 @empty
-                    <div class="wiki-card">
-                        <x-ui.empty-state title="暂无文集" />
-                    </div>
+                <div class="wiki-card">
+                    <x-ui.empty-state title="暂无文集" />
+                </div>
                 @endforelse
                 @endforelse
 
 
                 {{-- 分页 --}}
                 {{-- 分页 --}}
@@ -57,8 +56,7 @@
                     <x-ui.search-input
                     <x-ui.search-input
                         :action="route('library.search')"
                         :action="route('library.search')"
                         placeholder="搜索文集…"
                         placeholder="搜索文集…"
-                        :hidden-fields="['type' => 'anthology']"
-                    />
+                        :hidden-fields="['resource_type' => 'anthology']" />
                 </div>
                 </div>
 
 
                 {{-- 作者列表 --}}
                 {{-- 作者列表 --}}
@@ -75,8 +73,7 @@
                                     :initials="$author['initials']"
                                     :initials="$author['initials']"
                                     :name="$author['name']"
                                     :name="$author['name']"
                                     :sub="$author['count'] . ' 篇文集'"
                                     :sub="$author['count'] . ' 篇文集'"
-                                    size="md"
-                                />
+                                    size="md" />
                             </a>
                             </a>
                         </li>
                         </li>
                         @endforeach
                         @endforeach

+ 2 - 18
api-v12/resources/views/library/book/read.blade.php

@@ -161,24 +161,8 @@
                 </p>
                 </p>
 
 
                 <div class="content">
                 <div class="content">
-                    @if(isset($book['content']) && count($book['content']) > 0)
-                    @foreach ($book['content'] as $paragraph)
-                    <div id="para-{{ $paragraph['id'] }}">
-                        @foreach ($paragraph['text'] as $rows)
-                        <div style="display:flex;">
-                            @foreach ($rows as $col)
-                            <div style="flex:1;">
-                                @if($paragraph['level'] < 8)
-                                    <h{{ $paragraph['level'] }}>{!! $col !!}</h{{ $paragraph['level'] }}>
-                                    @else
-                                    <p>{!! $col !!}</p>
-                                    @endif
-                            </div>
-                            @endforeach
-                        </div>
-                        @endforeach
-                    </div>
-                    @endforeach
+                    @if(isset($book['content']))
+                    {{!! $book['content'] !!}}
                     @else
                     @else
                     <div>没有内容</div>
                     <div>没有内容</div>
                     @endif
                     @endif

+ 73 - 40
api-v12/resources/views/library/search.blade.php

@@ -6,6 +6,7 @@
 --}}
 --}}
 @extends('library.wiki.layouts.app')
 @extends('library.wiki.layouts.app')
 
 
+
 @section('title', $query ? '"' . $query . '" 的搜索结果 · WikiPāli' : '搜索 · WikiPāli')
 @section('title', $query ? '"' . $query . '" 的搜索结果 · WikiPāli' : '搜索 · WikiPāli')
 
 
 @section('wiki-content')
 @section('wiki-content')
@@ -23,46 +24,46 @@
 {{-- 结果摘要 --}}
 {{-- 结果摘要 --}}
 <div class="wiki-search-summary">
 <div class="wiki-search-summary">
     @if ($query)
     @if ($query)
-        搜索 <strong>「{{ $query }}」</strong>
-        @if ($pagination['total'] > 0)
-            ,共找到 <strong>{{ $pagination['total'] }}</strong> 条结果
-            @if ($pagination['last_page'] > 1)
-                (第 {{ $pagination['current_page'] }} / {{ $pagination['last_page'] }} 页)
-            @endif
-        @else
-            ,未找到相关条目
-        @endif
+    搜索 <strong>「{{ $query }}」</strong>
+    @if ($pagination['total'] > 0)
+    ,共找到 <strong>{{ $pagination['total'] }}</strong> 条结果
+    @if ($pagination['last_page'] > 1)
+    (第 {{ $pagination['current_page'] }} / {{ $pagination['last_page'] }} 页)
+    @endif
+    @else
+    ,未找到相关条目
+    @endif
     @endif
     @endif
 </div>
 </div>
 
 
 {{-- 结果列表 --}}
 {{-- 结果列表 --}}
 @if (count($results) > 0)
 @if (count($results) > 0)
 
 
-    <div class="wiki-card wiki-search-results">
-        @foreach ($results as $result)
-            <x-wiki.search-result-card :result="$result" :lang="$lang" />
-        @endforeach
-    </div>
+<div class="wiki-card wiki-search-results">
+    @foreach ($results as $result)
+    <x-wiki.search-result-card :result="$result" :lang="$lang" />
+    @endforeach
+</div>
 
 
-    {{-- 分页 --}}
-    @if ($pagination['last_page'] > 1)
-        <x-wiki.pagination
-            :pagination="$pagination"
-            routeName="library.search"
-            :queryParams="array_filter([
+{{-- 分页 --}}
+@if ($pagination['last_page'] > 1)
+<x-wiki.pagination
+    :pagination="$pagination"
+    routeName="library.search"
+    :queryParams="array_filter([
                 'q'        => $query,
                 'q'        => $query,
                 'lang'     => $lang,
                 'lang'     => $lang,
                 'category' => $category !== 'all' ? $category : null,
                 'category' => $category !== 'all' ? $category : null,
             ])" />
             ])" />
-    @endif
+@endif
 
 
 @else
 @else
 
 
-    <div class="wiki-card">
-        <x-ui.empty-state
-            title="未找到相关条目"
-            desc="请尝试其他关键词" />
-    </div>
+<div class="wiki-card">
+    <x-ui.empty-state
+        title="未找到相关条目"
+        desc="请尝试其他关键词" />
+</div>
 
 
 @endif
 @endif
 
 
@@ -71,29 +72,31 @@
 @section('wiki-sidebar')
 @section('wiki-sidebar')
 
 
 {{-- 分类筛选 --}}
 {{-- 分类筛选 --}}
+@isset($filters)
+@foreach ($filters as $key=>$filter)
 <div class="wiki-sidebar-section">
 <div class="wiki-sidebar-section">
-    <div class="wiki-sidebar-title">按分类筛选</div>
+    <div class="wiki-sidebar-title">按{{ $key }}筛选</div>
     <ul class="wiki-cat-list">
     <ul class="wiki-cat-list">
         <li>
         <li>
             <a href="{{ route('library.search', ['q' => $query]) }}"
             <a href="{{ route('library.search', ['q' => $query]) }}"
-               class="{{ $category === 'all' ? 'active' : '' }}">
+                class="{{ $category === 'all' ? 'active' : '' }}">
                 全部
                 全部
-                <span class="wiki-cat-count">{{ $pagination['total'] }}</span>
             </a>
             </a>
         </li>
         </li>
-        @isset($filters)
-            @foreach ($filters as $cat)
-            <li>
-                <a href="{{ route('library.search', ['q' => $query, 'category' => $cat->key]) }}"
-                   class="{{ $category === $cat->key ? 'active' : '' }}">
-                    {{ $cat->key }}
-                    <span class="wiki-cat-count">{{ $cat->doc_count }}</span>
-                </a>
-            </li>
-            @endforeach
-        @endisset
+
+        @foreach ($filter['buckets'] as $bucket)
+        <li>
+            <a href="{{ route('library.search', ['q' => $query, $key => $bucket['key']]) }}"
+                class="{{ $category === $bucket['key'] ? 'active' : '' }}">
+                {{ $bucket['key'] }}
+                <span class="wiki-cat-count">{{ $bucket['doc_count'] }}</span>
+            </a>
+        </li>
+        @endforeach
     </ul>
     </ul>
 </div>
 </div>
+@endforeach
+@endisset
 
 
 {{-- 近似词条(无结果时显示) --}}
 {{-- 近似词条(无结果时显示) --}}
 @if (count($results) === 0 && $query)
 @if (count($results) === 0 && $query)
@@ -110,3 +113,33 @@
 @endif
 @endif
 
 
 @endsection
 @endsection
+
+
+@section('wiki-sidebar-left')
+
+
+
+<div class="wiki-sidebar-section">
+    <div class="wiki-sidebar-title">分类浏览</div>
+    <ul class="wiki-cat-list">
+        <li>
+            <a href="{{ route('library.search', ['q' => $query]) }}"
+                class="{{ $category === 'all' ? 'active' : '' }}">
+                全部
+            </a>
+        </li>
+        @foreach ($types as $type)
+        <li>
+            <a href="{{ route('library.search', ['q' => $query,'resource_type' => $type['slug']]) }}"
+                class="{{ (request('resource_type', 'all') === $type['slug']) ? 'active' : '' }}">
+                {{ $type['label'] }}
+                @if(isset($type['count']))
+                <span class="wiki-cat-count">{{ $type['count'] }}</span>
+                @endif
+            </a>
+        </li>
+        @endforeach
+    </ul>
+</div>
+
+@endsection

+ 1 - 1
api-v12/resources/views/library/tipitaka/category.blade.php

@@ -75,7 +75,7 @@
                     :action="route('library.search')"
                     :action="route('library.search')"
                     :value="request('q')"
                     :value="request('q')"
                     placeholder="搜索三藏原文译文"
                     placeholder="搜索三藏原文译文"
-                    :hidden-fields="['type' => 'tipitaka']" />
+                    :hidden-fields="['resource_type' => 'tipitaka']" />
             </div>
             </div>
 
 
             {{-- 1. 子分类 --}}
             {{-- 1. 子分类 --}}

+ 21 - 7
api-v12/resources/views/library/tipitaka/show.blade.php

@@ -95,13 +95,27 @@
                 <ul class="wiki-toc-list">
                 <ul class="wiki-toc-list">
                     @foreach($book['contents'] as $chapter)
                     @foreach($book['contents'] as $chapter)
                     <li>
                     <li>
-                        <a href="{{ route('library.tipitaka.read', $chapter['id']) }}?channel={{ $chapter['channel'] }}">
-                            <div style="display: flex;">
-                                <div>{{ $chapter['title'] }}</div>
-                                <div>
-                                    <span style="margin-left: auto; font-size: 0.75rem; color: var(--tblr-secondary);">
-                                        {{ $chapter['progress'] }}%
-                                    </span>
+                        <a href="{{ route('library.tipitaka.read', $chapter['id']) }}?channel={{ $chapter['channel'] }}" target="_blank">
+                            <div class="toc-item">
+                                <div class="toc-title">
+                                    {{ $chapter['title'] }}
+                                </div>
+
+                                <div class="toc-progress">
+                                    @php
+                                    $p = $chapter['progress'];
+                                    $color = $p >= 80 ? 'bg-green' : ($p >= 30 ? 'bg-yellow' : 'bg-red');
+                                    @endphp
+
+                                    <div class="progress" style="height: 6px;">
+                                        <div
+                                            class="progress-bar {{ $color }}"
+                                            style="width: {{ $p }}%">
+                                        </div>
+                                    </div>
+                                    <div class="toc-progress-text">
+                                        {{ $p }}%
+                                    </div>
                                 </div>
                                 </div>
                             </div>
                             </div>
                             @if(isset($chapter['summary']))
                             @if(isset($chapter['summary']))

+ 4 - 3
api-v12/resources/views/library/wiki/home.blade.php

@@ -25,11 +25,12 @@
 
 
     {{-- 搜索框 --}}
     {{-- 搜索框 --}}
     <div class="wiki-home-search">
     <div class="wiki-home-search">
-        <x-wiki.search-box
+        <x-ui.search-input
             :action="route('library.search')"
             :action="route('library.search')"
+            :value="request('q')"
             placeholder="搜索佛法词条、经典、人物..."
             placeholder="搜索佛法词条、经典、人物..."
-            button-text="搜索"
-            size="lg" />
+            size="lg"
+            :hidden-fields="['resource_type' => 'term']" />
     </div>
     </div>
 
 
     {{-- 热门搜索标签 --}}
     {{-- 热门搜索标签 --}}

+ 53 - 3
api-v12/resources/views/library/wiki/index.blade.php

@@ -6,13 +6,15 @@
 @section('wiki-content')
 @section('wiki-content')
 {{-- 搜索框组件 --}}
 {{-- 搜索框组件 --}}
 <div class="wiki-search-wrapper">
 <div class="wiki-search-wrapper">
-    <x-wiki.search-box
+    <x-ui.search-input
         :action="route('library.search')"
         :action="route('library.search')"
+        :value="request('q')"
         placeholder="搜索佛法词条、经典、人物..."
         placeholder="搜索佛法词条、经典、人物..."
-        button-text="搜索"
-        size="lg" />
+        size="lg"
+        :hidden-fields="['resource_type' => 'term']" />
 </div>
 </div>
 {{-- 今日条目 --}}
 {{-- 今日条目 --}}
+@isset($today)
 <div class="wiki-today-banner">
 <div class="wiki-today-banner">
     <div class="wiki-today-icon">☸</div>
     <div class="wiki-today-icon">☸</div>
     <div class="wiki-today-body">
     <div class="wiki-today-body">
@@ -27,8 +29,10 @@
         </a>
         </a>
     </div>
     </div>
 </div>
 </div>
+@endisset
 
 
 {{-- 精选条目 --}}
 {{-- 精选条目 --}}
+@if(isset($featured) && is_array($featured) && count($featured)>0)
 <div class="wiki-card">
 <div class="wiki-card">
     <div class="wiki-sidebar-title" style="margin-bottom: 14px;">精选条目</div>
     <div class="wiki-sidebar-title" style="margin-bottom: 14px;">精选条目</div>
     <div class="wiki-featured-grid">
     <div class="wiki-featured-grid">
@@ -42,6 +46,34 @@
         @endforeach
         @endforeach
     </div>
     </div>
 </div>
 </div>
+@endif
+
+
+@if(isset($subs) && is_array($subs) && count($subs) > 0)
+
+{{-- 取一级分类名称作为标题 --}}
+@php
+$catLabel = collect(config('taxonomy'))->firstWhere('id', $category)['label'] ?? $category;
+@endphp
+
+<div class="wiki-card wiki-subcat-block">
+
+    <div class="wiki-subcat-block-header">
+        <span class="wiki-subcat-block-title">{{ $catLabel }}</span>
+        <a class="wiki-subcat-block-more"
+            href="{{ route('library.wiki.index', ['lang' => $lang]) }}?category={{ $category }}">
+            浏览全部
+        </a>
+    </div>
+
+    @foreach ($subs as $sub)
+    <x-wiki.sub-category :sub="$sub" :lang="$lang" />
+    @endforeach
+
+</div>
+
+@endif
+
 
 
 @endsection
 @endsection
 
 
@@ -65,4 +97,22 @@
     </table>
     </table>
 </div>
 </div>
 
 
+
+
+
+<div class="wiki-sidebar-section">
+    <div class="wiki-sidebar-title">质量等级</div>
+    <ul class="wiki-cat-list" id="qualityFilterList">
+        @foreach ($qualities as $q)
+        <li>
+            <a href="{{ request()->fullUrlWithQuery(['quality' => $q['value']]) }}"
+                class="wiki-quality-filter-item {{ $quality === $q['value'] ? 'active' : '' }}"
+                data-quality="{{ $q['value'] }}">
+                <span>{{ $q['label'] }}</span><span>{{ $q['subtitle'] }}</span>
+            </a>
+        </li>
+        @endforeach
+    </ul>
+</div>
+
 @endsection
 @endsection

+ 46 - 40
api-v12/resources/views/library/wiki/layouts/app.blade.php

@@ -6,7 +6,7 @@
 @extends('layouts.library')
 @extends('layouts.library')
 
 
 @push('styles')
 @push('styles')
-    @vite('resources/css/modules/_wiki.css')
+@vite('resources/css/modules/_wiki.css')
 @endpush
 @endpush
 
 
 @section('content')
 @section('content')
@@ -15,49 +15,55 @@
 
 
     {{-- 左侧边栏 --}}
     {{-- 左侧边栏 --}}
     @hasSection('wiki-sidebar-left')
     @hasSection('wiki-sidebar-left')
-        <aside class="wiki-sidebar-left">
-            @yield('wiki-sidebar-left')
-        </aside>
+    <aside class="wiki-sidebar-left">
+        @yield('wiki-sidebar-left')
+    </aside>
     @else
     @else
-        @if(isset($lang))
-        <aside class="wiki-sidebar-left">
+    @if(isset($lang))
+    <aside class="wiki-sidebar-left">
 
 
-            @isset($categories)
-            <div class="wiki-sidebar-section">
-                <div class="wiki-sidebar-title">分类浏览</div>
-                <ul class="wiki-cat-list">
-                    @foreach ($categories as $cat)
-                    <li>
-                        <a href="{{ route('library.wiki.index', ['lang' => $lang]) }}?category={{ $cat['slug'] }}"
-                           class="{{ (request('category', 'all') === $cat['slug']) ? 'active' : '' }}">
-                            {{ $cat['label'] }}
-                            @if(isset($cat['count']))
-                                <span class="wiki-cat-count">{{ $cat['count'] }}</span>
-                            @endif
-                        </a>
-                    </li>
-                    @endforeach
-                </ul>
-            </div>
-            @endisset
+        @isset($categories)
+        <div class="wiki-sidebar-section">
+            <div class="wiki-sidebar-title">分类浏览</div>
+            <ul class="wiki-cat-list">
+                <li>
+                    <a href="{{ route('library.wiki.index', ['lang' => $lang]) }}"
+                        class="{{ (request('category', 'all') === 'all') ? 'active' : '' }}">
+                        全部
+                    </a>
+                </li>
+                @foreach ($categories as $cat)
+                <li>
+                    <a href="{{ route('library.wiki.index', ['lang' => $lang]) }}?category={{ $cat['id'] }}"
+                        class="{{ (request('category', 'all') === $cat['id']) ? 'active' : '' }}">
+                        {{ $cat['label'] }}
+                        @if(isset($cat['count']))
+                        <span class="wiki-cat-count">{{ $cat['count'] }}</span>
+                        @endif
+                    </a>
+                </li>
+                @endforeach
+            </ul>
+        </div>
+        @endisset
 
 
-            @isset($recentUpdates)
-            <div class="wiki-sidebar-section">
-                <div class="wiki-sidebar-title">最近更新</div>
-                <ul class="wiki-cat-list">
-                    @foreach ($recentUpdates as $item)
-                    <li>
-                        <a href="{{ route('library.wiki.show', [$item['lang'], $item['word']]) }}">
-                            {{ $item['word'] }}
-                        </a>
-                    </li>
-                    @endforeach
-                </ul>
-            </div>
-            @endisset
+        @isset($recentUpdates)
+        <div class="wiki-sidebar-section">
+            <div class="wiki-sidebar-title">最近更新</div>
+            <ul class="wiki-cat-list">
+                @foreach ($recentUpdates as $item)
+                <li>
+                    <a href="{{ route('library.wiki.show', [$item['lang'], $item['word']]) }}">
+                        {{ $item['word'] }}
+                    </a>
+                </li>
+                @endforeach
+            </ul>
+        </div>
+        @endisset
 
 
-        </aside>
-        @endif
+    </aside>
+    @endif
     @endif
     @endif
 
 
     {{-- 主内容区 --}}
     {{-- 主内容区 --}}

+ 9 - 4
api-v12/resources/views/library/wiki/show.blade.php

@@ -6,13 +6,18 @@
 @section('wiki-content')
 @section('wiki-content')
 {{-- 搜索框组件 --}}
 {{-- 搜索框组件 --}}
 <div class="wiki-search-wrapper">
 <div class="wiki-search-wrapper">
-    <x-wiki.search-box
+    <x-ui.search-input
         :action="route('library.search')"
         :action="route('library.search')"
+        :value="request('q')"
         placeholder="搜索佛法词条、经典、人物..."
         placeholder="搜索佛法词条、经典、人物..."
-        button-text="搜索"
-        size="lg" />
+        size="lg"
+        :hidden-fields="['resource_type' => 'term']" />
 </div>
 </div>
-<article class="wiki-card">
+<article class="wiki-card" style="position: relative;">
+
+    <x-wiki.entry-actions
+        :editUrl="$entry['edit_url']"
+        :title="$entry['zh']" />
 
 
     {{-- 条目头部 --}}
     {{-- 条目头部 --}}
     <x-wiki.entry-header :entry="$entry" />
     <x-wiki.entry-header :entry="$entry" />

+ 5 - 4
api-v12/routes/web.php

@@ -6,13 +6,14 @@ use App\Http\Controllers\WbwAnalysisController;
 use App\Http\Controllers\PageIndexController;
 use App\Http\Controllers\PageIndexController;
 use App\Http\Controllers\AssetsController;
 use App\Http\Controllers\AssetsController;
 use App\Http\Controllers\BlogController;
 use App\Http\Controllers\BlogController;
-use App\Http\Controllers\CategoryController;
 use App\Http\Controllers\DownloadController;
 use App\Http\Controllers\DownloadController;
 use App\Http\Controllers\Library\AnthologyController;
 use App\Http\Controllers\Library\AnthologyController;
 use App\Http\Controllers\Library\AnthologyReadController;
 use App\Http\Controllers\Library\AnthologyReadController;
 use App\Http\Controllers\Library\BookController;
 use App\Http\Controllers\Library\BookController;
 use App\Http\Controllers\Library\WikiController;
 use App\Http\Controllers\Library\WikiController;
 use App\Http\Controllers\Library\SearchController;
 use App\Http\Controllers\Library\SearchController;
+use App\Http\Controllers\Library\HomeController;
+use App\Http\Controllers\Library\TipitakaController;
 
 
 /*
 /*
 |--------------------------------------------------------------------------
 |--------------------------------------------------------------------------
@@ -64,10 +65,10 @@ Route::post('/logout', function () {
 })->name('logout');
 })->name('logout');
 
 
 Route::prefix('library')->name('library.')->group(function () {
 Route::prefix('library')->name('library.')->group(function () {
-    Route::get('/', [CategoryController::class, 'home'])->name('home');
+    Route::get('/', [HomeController::class, 'index'])->name('home');
 
 
-    Route::get('/tipitaka', [CategoryController::class, 'category'])->name('tipitaka.index');
-    Route::get('/tipitaka/category/{id}', [CategoryController::class, 'category'])->name('tipitaka.category');
+    Route::get('/tipitaka', [TipitakaController::class, 'index'])->name('tipitaka.index');
+    Route::get('/tipitaka/category/{id}', [TipitakaController::class, 'index'])->name('tipitaka.category');
     Route::get('/tipitaka/{id}', [BookController::class, 'show'])->name('tipitaka.show');
     Route::get('/tipitaka/{id}', [BookController::class, 'show'])->name('tipitaka.show');
     Route::get('/tipitaka/{id}/read', [BookController::class, 'read'])->name('tipitaka.read');
     Route::get('/tipitaka/{id}/read', [BookController::class, 'read'])->name('tipitaka.read');
 
 

+ 16 - 7
api-v13/.env.example

@@ -11,7 +11,7 @@ APP_FAKER_LOCALE=en_US
 APP_MAINTENANCE_DRIVER=file
 APP_MAINTENANCE_DRIVER=file
 # APP_MAINTENANCE_STORE=database
 # APP_MAINTENANCE_STORE=database
 
 
-# PHP_CLI_SERVER_WORKERS=4
+PHP_CLI_SERVER_WORKERS=4
 
 
 BCRYPT_ROUNDS=12
 BCRYPT_ROUNDS=12
 
 
@@ -20,12 +20,12 @@ LOG_STACK=single
 LOG_DEPRECATIONS_CHANNEL=null
 LOG_DEPRECATIONS_CHANNEL=null
 LOG_LEVEL=debug
 LOG_LEVEL=debug
 
 
-DB_CONNECTION=sqlite
-# DB_HOST=127.0.0.1
-# DB_PORT=3306
-# DB_DATABASE=laravel
-# DB_USERNAME=root
-# DB_PASSWORD=
+DB_CONNECTION=pgsql
+DB_HOST=127.0.0.1
+DB_PORT=5432
+DB_DATABASE=mint
+DB_USERNAME=root
+DB_PASSWORD=
 
 
 SESSION_DRIVER=database
 SESSION_DRIVER=database
 SESSION_LIFETIME=120
 SESSION_LIFETIME=120
@@ -63,3 +63,12 @@ AWS_BUCKET=
 AWS_USE_PATH_STYLE_ENDPOINT=false
 AWS_USE_PATH_STYLE_ENDPOINT=false
 
 
 VITE_APP_NAME="${APP_NAME}"
 VITE_APP_NAME="${APP_NAME}"
+
+OPENSEARCH_SCHEME=http
+OPENSEARCH_HOST=127.0.0.1
+OPENSEARCH_PORT=9200
+OPENSEARCH_USERNAME=""
+OPENSEARCH_PASSWORD=""
+OPENSEARCH_SSL_VERIFICATION=false
+
+JWT_SECRETS_KEY=

+ 168 - 0
api-v13/app/Console/Commands/AiTranslate.php

@@ -0,0 +1,168 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\Sentence;
+use App\Models\PaliSentence;
+use App\Models\PaliText;
+
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Http;
+
+class AiTranslate extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan ai:sentence.translate --type=chapter --api=deepseek --model=deepseek-chat --sid=107-2357
+     * php artisan ai:sentence.translate --type=sentence --api=kimi --model=moonshot-v1-8k --sid=107-2357-9-47
+     * @var string
+     */
+    protected $signature = <<<command
+    ai:sentence.translate 
+    {--type=sentence  : sentence|paragraph|chapter} 
+    {--api=  : ai engin url} 
+    {--model=  : ai model } 
+    {--sid=  : 句子编号 } 
+    {--nissaya=  : nissaya channel } 
+    {--result=  : result channel } 
+    command;
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '使用LLM 和nissaya数据翻译句子';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        //句子号列表
+        $sentences = array();
+        $totalLen = 0;
+        switch ($this->option('type')) {
+            case 'sentence':
+                $sentences[] = explode('-', $this->option('sid'));
+                break;
+            case 'paragraph':
+                $para = explode('-', $this->option('sid'));
+                $sent = PaliSentence::where('book', $para[0])
+                    ->where('paragraph', $para[1])->orderBy('word_begin')->get();
+                foreach ($sent as $key => $value) {
+                    $sentences[] = [$para[0], $para[1], $value->word_begin, $value->word_end];
+                }
+                break;
+            case 'chapter':
+                $para = explode('-', $this->option('sid'));
+                $chapterLen = PaliText::where('book', $para[0])
+                    ->where('paragraph', $para[1])->value('chapter_len');
+                $sent = PaliSentence::where('book', $para[0])
+                    ->whereBetween('paragraph', [$para[1], $para[1] + $chapterLen - 1])
+                    ->orderBy('paragraph')
+                    ->orderBy('word_begin')->get();
+                foreach ($sent as $key => $value) {
+                    $sentences[] = [$para[0], $para[1], $value->word_begin, $value->word_end];
+                }
+                break;
+            default:
+                return 1;
+                break;
+        }
+        //获取句子总长度
+
+        foreach ($sentences as $key => $sentence) {
+            $totalLen += $this->sentLen($sentence);
+        }
+        //
+        foreach ($sentences as $key => $sentence) {
+            # 获取巴利句子
+            $pali = PaliSentence::where('book', $sentence[0])
+                ->where('paragraph', $sentence[1])
+                ->where('word_begin', $sentence[2])
+                ->where('word_end', $sentence[3])
+                ->value('text');
+            //获取nissaya
+            $nissaya = Sentence::where('channel_uid', $this->option('nissaya'))
+                ->where('book_id', $sentence[0])
+                ->where('paragraph', $sentence[1])
+                ->where('word_start', $sentence[2])
+                ->where('word_end', $sentence[3])
+                ->value('content');
+            //获取ai结果
+            $api = $this->getEngin($this->option('api'));
+            if (!$api) {
+                $this->error('ai translate no api');
+                return 1;
+            }
+            $json = $this->fetch($api, $this->option('model'), $pali, $nissaya);
+            Log::info('ai translate', ['json' => $json]);
+            $this->info($json['choices'][0]['message']['content']);
+            //写入
+        }
+        return 0;
+    }
+
+    private function sentLen($id)
+    {
+        return PaliSentence::where('book', $id[0])
+            ->where('paragraph', $id[1])
+            ->where('word_begin', $id[2])
+            ->where('word_end', $id[3])
+            ->value('length');
+    }
+    private function getEngin($engin)
+    {
+        $api = config('mint.ai.accounts');
+        $selected = array_filter($api, function ($value) use ($engin) {
+            return $value['name'] === $engin;
+        });
+        if (!is_array($selected) || count($selected) === 0) {
+            return null;
+        }
+        return $selected[0];
+    }
+
+    private function fetch($api, $model, $origin,  $nissaya = null)
+    {
+        $prompt = '翻译上面的巴利文为中文';
+        if ($nissaya) {
+            $prompt = '根据下面的解释,' . $prompt;
+        }
+        $message = "{$origin}\n\n{$prompt}\n\n{$nissaya}";
+
+        $url = $api['api_url'];
+        $param = [
+            "model" => $model,
+            "messages" => [
+                ["role" => "system", "content" => "你是翻译人工智能助手.bhikkhu 为专有名词,不可翻译成其他语言。"],
+                ["role" => "user", "content" => $message],
+            ],
+            "temperature" => 0.3,
+            "stream" => false
+        ];
+        $response = Http::withToken($api['token'])
+            ->post($url, $param);
+        if ($response->failed()) {
+            $this->error('http request error' . $response->json('message'));
+            Log::error('http request error', ['data' => $response->json()]);
+            return null;
+        } else {
+            return $response->json();
+        }
+    }
+}

+ 77 - 0
api-v13/app/Console/Commands/CacheDictPreference.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\UserDict;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Cache;
+
+class CacheDictPreference extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'cache:dict.preference';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '从第三方字典中提取首选项';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+        $prefix = 'dict-preference';
+        $words = UserDict::select(['word', 'language'])
+            ->groupBy(['word', 'language'])
+            ->cursor();
+        $wordCount = DB::select('SELECT count(*) from (
+                     SELECT word,language from user_dicts group by word,language) T');
+        $bar = $this->output->createProgressBar($wordCount[0]->count);
+        $count = 0;
+        foreach ($words as $key => $word) {
+            $meaning = UserDict::where('word', $word->word)
+                ->where('language', $word->language)
+                ->where('source', '_PAPER_RICH_')
+                ->whereNotNull('mean')
+                ->value('mean');
+            $meaning = trim($meaning, " $");
+            if (!empty($meaning)) {
+                $m = explode('$', $meaning);
+                Cache::put("{$prefix}/{$word->word}/{$word->language}", $m[0]);
+            }
+            $bar->advance();
+            $count++;
+            if ($count % 1000 === 0) {
+                if (\App\Tools\Tools::isStop()) {
+                    return 0;
+                }
+            }
+        }
+        $bar->finish();
+
+        return 0;
+    }
+}

+ 120 - 0
api-v13/app/Console/Commands/CacheWbwPreference.php

@@ -0,0 +1,120 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Cache;
+use App\Models\WbwAnalysis;
+use Illuminate\Support\Facades\DB;
+
+class CacheWbwPreference extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'cache:wbw.preference {--editor=} {--view=all}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '逐词解析的首选项预热';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+        $prefix = 'wbw-preference';
+        if (
+            $this->option('view') === 'all' ||
+            $this->option('view') === 'my'
+        ) {
+            $this->info('个人数据');
+            /**
+             * 个人数据算法
+             * 最新优先
+             */
+            $wbw = WbwAnalysis::select(['wbw_word', 'type', 'editor_id']);
+            $wbwCount = DB::select('SELECT count(*) from (
+                SELECT wbw_word,type,editor_id from wbw_analyses group by wbw_word,type,editor_id) T');
+            if ($this->option('editor')) {
+                $wbw = $wbw->where('editor_id', $this->option('editor'));
+                $wbwCount = DB::select(
+                    'SELECT count(*) from (
+                    SELECT wbw_word,type,editor_id from wbw_analyses where editor_id=? group by wbw_word,type,editor_id) T',
+                    [$this->option('editor')]
+                );
+            }
+            $wbw = $wbw->groupBy(['wbw_word', 'type', 'editor_id'])->cursor();
+            $bar = $this->output->createProgressBar($wbwCount[0]->count);
+            $count = 0;
+            foreach ($wbw as $key => $value) {
+                $data = WbwAnalysis::where('wbw_word', $value->wbw_word)
+                    ->where('type', $value->type)
+                    ->where('editor_id', $value->editor_id)
+                    ->orderBy('updated_at', 'desc')
+                    ->value('data');
+                Cache::put("{$prefix}/{$value->wbw_word}/{$value->type}/{$value->editor_id}", $data);
+                $bar->advance();
+                $count++;
+                if ($count % 1000 === 0) {
+                    if (\App\Tools\Tools::isStop()) {
+                        return 0;
+                    }
+                }
+            }
+            $bar->finish();
+        }
+
+        if (
+            $this->option('view') === 'all' ||
+            $this->option('view') === 'community'
+        ) {
+            $this->info('社区通用');
+            /**
+             * 社区数据算法
+             * 多的优先
+             */
+            $wbw = WbwAnalysis::select(['wbw_word', 'type']);
+            $count = DB::select('SELECT count(*) from (
+                SELECT wbw_word,type from wbw_analyses group by wbw_word,type) T');
+            $wbw = $wbw->groupBy(['wbw_word', 'type'])->cursor();
+
+            $bar = $this->output->createProgressBar($count[0]->count);
+            foreach ($wbw as $key => $value) {
+                $data = WbwAnalysis::where('wbw_word', $value->wbw_word)
+                    ->where('type', $value->type)
+                    ->selectRaw('data,count(*)')
+                    ->groupBy("data")
+                    ->orderBy("count", "desc")
+                    ->first();
+
+                Cache::put("{$prefix}/{$value->wbw_word}/{$value->type}/0", $data->data);
+                $bar->advance();
+            }
+            $bar->finish();
+        }
+
+        return 0;
+    }
+}

+ 45 - 0
api-v13/app/Console/Commands/ClearEmbeddingsCache.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Services\OpenSearchService;
+
+class ClearEmbeddingsCache extends Command
+{
+    /**
+     * 命令名称
+     *
+     * @var string
+     */
+    protected $signature = 'embeddings:clear {text? : 指定要清理的文本,不传则清理全部缓存}';
+
+    /**
+     * 命令描述
+     *
+     * @var string
+     */
+    protected $description = '清理 Redis 中的 embedding 缓存';
+
+    /**
+     * 执行命令
+     */
+    public function handle(OpenSearchService $service)
+    {
+        $text = $this->argument('text');
+
+        if ($text) {
+            $ok = $service->clearEmbeddingCache($text);
+            if ($ok) {
+                $this->info("已清理指定文本的缓存: \"{$text}\"");
+            } else {
+                $this->warn("缓存不存在: \"{$text}\"");
+            }
+        } else {
+            $count = $service->clearAllEmbeddingCache();
+            $this->info("已清理所有 embedding 缓存,共 {$count} 条");
+        }
+
+        return 0;
+    }
+}

+ 180 - 0
api-v13/app/Console/Commands/CopyUserBook.php

@@ -0,0 +1,180 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\CustomBookSentence;
+use App\Models\CustomBook;
+
+use App\Models\Channel;
+use App\Models\Sentence;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Log;
+
+class CopyUserBook extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan copy:user.book
+     * @var string
+     */
+    protected $signature = 'copy:user.book {--lang} {--book=} {--test}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '复制用户自定书到sentence表';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        //获取全部语言列表
+        $lang = CustomBookSentence::select('lang')->groupBy('lang')->get();
+        foreach ($lang as $key => $value) {
+            $this->info('language:'.$value->lang);
+        }
+        if($this->option('lang')){
+            return 0;
+        }
+
+        if($this->option('test')){
+            $this->info('run in test mode');
+        }
+
+        $this->info('给CustomBook 添加channel');
+        $newChannel = 0;
+
+        foreach (CustomBook::get() as $key => $customBook) {
+            $this->info('doing book='.$customBook->book_id);
+            if(empty($customBook->channel_id)){
+                $bookLang = $customBook->lang;
+                if(empty($bookLang) || $bookLang === 'false' || $bookLang === 'null'  || $bookLang === 'none'){
+                    $this->info('language can not be empty change to pa, book='.$customBook->book_id);
+                    Log::warning('copy:user.book language can not be empty ,change to pa, book='.$customBook->book_id);
+                    $bookLang = 'pa';
+                }
+                $customBook->lang = $bookLang;
+                $channelName = '_user_book_'.$bookLang;
+                $channel = Channel::where('owner_uid',$customBook->owner)
+                                ->where('name',$channelName)->first();
+                if($channel === null){
+                    $this->info('create new channel');
+                    $channelUuid = Str::uuid();
+                    $channel = new Channel;
+                    $channel->id = app('snowflake')->id();
+                    $channel->uid = $channelUuid;
+                    $channel->owner_uid = $customBook->owner;
+                    $channel->name = $channelName;
+                    $channel->type = 'original';
+                    $channel->lang = $bookLang;
+                    $channel->editor_id = 0;
+                    $channel->is_system = true;
+                    $channel->create_time = time()*1000;
+                    $channel->modify_time = time()*1000;
+                    $channel->status = $customBook->status;
+                    if(!$this->option('test')){
+                        $saveOk = $channel->save();
+                        if($saveOk){
+                            $newChannel++;
+                            Log::debug('copy user book : create channel success name='.$channelName);
+                        }else{
+                            Log::error('copy user book : create channel fail.',['channel'=>$channelName,'book'=>$customBook->book_id]);
+                            $this->error('copy user book : create channel fail.  name='.$channelName);
+                            continue;
+                        }
+                    }
+                }
+                if(!Str::isUuid($channel->uid)){
+                    Log::error('copy user book : channel id error.',['channel'=>$channelName,'book'=>$customBook->book_id]);
+                    $this->error('copy user book : channel id error.  name='.$channelName);
+                    continue;
+                }
+                $customBook->channel_id = $channel->uid;
+                if(!$this->option('test')){
+                    $ok = $customBook->save();
+                    if(!$ok){
+                        Log::error('copy user book : create channel fail.',['book'=>$customBook->book_id]);
+                        continue;
+                    }
+                }
+            }
+        }
+        $this->info('给CustomBook 添加channel 结束');
+
+        $userBooks = CustomBook::get();
+        $this->info('book '. count($userBooks));
+        $copySent = 0;
+        foreach ($userBooks as $key => $book) {
+
+            $queryBook = $this->option('book');
+            if(!empty($queryBook)){
+                if($book->book_id != $queryBook){
+                    continue;
+                }
+            }
+            if(empty($book->channel_id)){
+                $this->error('book channel is empty');
+                continue;
+            }
+            $this->info('doing book '. $book->book_id);
+
+            $bookSentence = CustomBookSentence::where('book',$book->book_id)->cursor();
+            foreach ($bookSentence as $key => $sentence) {
+                $newRow = Sentence::firstOrNew(
+                    [
+                        "book_id" => $sentence->book,
+                        "paragraph" => $sentence->paragraph,
+                        "word_start" => $sentence->word_start,
+                        "word_end" => $sentence->word_end,
+                        "channel_uid" => $book->channel_id,
+                    ],
+                    [
+                        'id' => app('snowflake')->id(),
+                        'uid' => Str::uuid(),
+                        'create_time' => $sentence->create_time,
+                        'modify_time' => $sentence->modify_time,
+                    ]
+                    );
+                $newRow->editor_uid = $sentence->owner;
+                $newRow->content = $sentence->content;
+                $newRow->strlen = mb_strlen($sentence->content,"UTF-8");
+                $newRow->status = $sentence->status;
+                $newRow->content_type = $sentence->content_type;
+                $newRow->language = $book->lang;
+                if(empty($newRow->channel_uid)){
+                    $this->error('channel uuid is null book='.$sentence->book .' para='.$sentence->paragraph);
+                    Log::error('channel uuid is null ',['sentence'=>$sentence->book]);
+                }else{
+                    if(!$this->option('test')){
+                        $ok = $newRow->save();
+                        if(!$ok){
+                            Log::error('copy fail ',['sentence'=>$sentence->id]);
+                        }
+                        $copySent++;
+                    }
+                }
+            }
+            $this->info("book {$book->book} finished");
+        }
+        $this->info('all done ');
+        $this->info('channel create '.$newChannel);
+        $this->info('sentence copy '.$copySent);
+        return 0;
+    }
+}

+ 114 - 0
api-v13/app/Console/Commands/CreateMyHanCrop.php

@@ -0,0 +1,114 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+
+class CreateMyHanCrop extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan create:my.han.crop --page=10
+     * @var string
+     */
+    protected $signature = 'create:my.han.crop {--page=}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '建立缅汉字典切图工作文件';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $csvFile = config("mint.path.dict_text") . '/zh/my-han/index.csv';
+        if (($fp = fopen($csvFile, "r")) !== false) {
+            $row = 0;
+            $currPage = 0;
+            $currPageWords = [];
+            while (($data = fgetcsv($fp, 0, ',')) !== false) {
+                $row++;
+                if ($row === 1) {
+                    continue;
+                }
+                if ($this->option('page')) {
+                    if ($currPage >= (int)$this->option('page')) {
+                        break;
+                    }
+                }
+                $page = (int)$data[1];
+                $word = $data[2];
+                if ($page !== $currPage) {
+                    //保存上一页数据
+                    $this->save($currPage, $currPageWords);
+                    $currPage = $page;
+                    //清空单词缓存
+                    $currPageWords = [];
+                }
+                $currPageWords[] = $word;
+            }
+            fclose($fp);
+            $this->save($currPage, $currPageWords);
+        }
+        $this->info('done');
+        return 0;
+    }
+    private function save($page, $words)
+    {
+        $basicUrl = 'https://ftp.wikipali.org/kosalla/%E7%BC%85%E6%96%87%E8%AF%8D%E5%85%B8/';
+        if (count($words) > 0) {
+            $m = new \Mustache_Engine(array(
+                'entity_flags' => ENT_QUOTES,
+                'escape' => function ($value) {
+                    return $value;
+                }
+            ));
+            $tplFile = resource_path("/mustache/my_han_crop.tpl");
+            $tpl = file_get_contents($tplFile);
+            $wordWithIndex = [];
+            foreach ($words as $key => $value) {
+                $wordWithIndex[] = [
+                    'index' => $key + 1,
+                    'word' => trim($value),
+                ];
+            }
+            $data = [
+                'dict' => [
+                    ['index' => 'a', 'img' => "{$basicUrl}{$page}A.jpg"],
+                    ['index' => 'b', 'img' => "{$basicUrl}{$page}B.jpg"],
+                    ['index' => 'a', 'img' => "{$basicUrl}" . ($page + 1) . "A.jpg"],
+                ],
+                'words' => $wordWithIndex
+            ];
+            $content = $m->render($tpl, $data);
+            //保存到临时文件夹
+            // 使用本地磁盘
+            // 创建目录]
+            $dir = '/tmp/export/myhan_crop/' . $page;
+            Storage::disk('local')->makeDirectory($dir);
+            Storage::disk('local')->makeDirectory($dir . '/img');
+            Storage::disk('local')->put($dir . "/index.html", $content);
+            Storage::disk('local')->put($dir . "/img/{$page}", $page);
+            $this->info("page={$page} word=" . count($words));
+        } else {
+            $this->error('page' . $page . 'no words');
+        }
+    }
+}

+ 75 - 0
api-v13/app/Console/Commands/CreateOpenSearchIndex.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\OpenSearchService;
+use Illuminate\Console\Command;
+
+class CreateOpenSearchIndex extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan create:opensearch.index
+     * @var string
+     */
+    protected $signature = 'create:opensearch.index';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $openSearch = app(OpenSearchService::class);
+
+        // Test OpenSearch connection
+        $open = $openSearch->testConnection();
+        if ($open[0]) {
+            $this->info($open[1]);
+        } else {
+            $this->error($open[1]);
+            return 1; // Exit with error code
+        }
+
+        // Attempt to create or update index
+        try {
+            $crate = $openSearch->createIndex();
+            if ($crate['acknowledged']) {
+                $this->info('Index created successfully: ' . $crate['index']);
+            }
+            if ($crate['shards_acknowledged']) {
+                $this->info('Shards initialized successfully for index: ' . $crate['index']);
+            } else {
+                $this->error('Shard initialization failed for index: ' . $crate['index']);
+                return 1;
+            }
+        } catch (\Exception $e) {
+            if (str_contains($e->getMessage(), 'exists')) {
+                $this->warn('Index already exists, attempting to update...');
+            } else {
+                $this->error('Failed to create index: ' . $e->getMessage());
+            }
+            return 1;
+        }
+
+        return 0;
+    }
+}

+ 100 - 0
api-v13/app/Console/Commands/ExportAiPaliWordToken.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\DictApi;
+use Illuminate\Support\Facades\Log;
+use App\Models\UserDict;
+
+class ExportAiPaliWordToken extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan export:ai.pali.word.token
+     * @var string
+     */
+    protected $signature = 'export:ai.pali.word.token {--format=gz  : zip file format 7z,lzma,gz }';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'export ai pali word token';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        Log::debug('export ai pali word token');
+
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+        $exportDir = storage_path('app/tmp/export/offline');
+        if (!is_dir($exportDir)) {
+            $res = mkdir($exportDir, 0755, true);
+            if (!$res) {
+                Log::error('mkdir fail path=' . $exportDir);
+                return 1;
+            } else {
+                Log::info('make dir successful ' . $exportDir);
+            }
+        }
+
+        $dict_id = DictApi::getSysDict('system_preference');
+        if (!$dict_id) {
+            Log::error('没有找到 system_preference 字典');
+            return 1;
+        }
+
+        $filename = 'ai-pali-word-token-' . date("Y-m-d") . '.tsv';
+        $exportFile = $exportDir . '/' . $filename;
+        $fp = fopen($exportFile, 'w');
+        if ($fp === false) {
+            Log::error('无法创建文件');
+            return 1;
+        }
+
+        $start = time();
+        $total = UserDict::where('dict_id', $dict_id)->count();
+        $words = UserDict::where('dict_id', $dict_id)
+            ->select([
+                'word',
+                'factors',
+            ])->cursor();
+        foreach ($words as $key => $word) {
+            $output = array($word->word, $word->factors);
+            fwrite($fp, implode("\t", $output) . "\n");
+            if ($key % 100 === 0) {
+                $present = (int)($key * 100 / $total);
+                $this->info("[{$present}%]-{$key}");
+            }
+        }
+        fclose($fp);
+        Log::info((time() - $start) . ' seconds');
+
+        $this->call('export:zip', [
+            'id' => 'ai-pali-word-token',
+            'filename' => $exportFile,
+            'title' => 'ai pali word token',
+            'format' => $this->option('format'),
+        ]);
+
+        return 0;
+    }
+}

+ 209 - 0
api-v13/app/Console/Commands/ExportAiTrainingData.php

@@ -0,0 +1,209 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use App\Models\Sentence;
+use App\Models\PaliSentence;
+use App\Http\Api\MdRender;
+use Illuminate\Support\Facades\File;
+use App\Http\Api\ChannelApi;
+use App\Services\PaliTextService;
+
+class ExportAiTrainingData extends Command
+{
+    private $ShortTrans = 0.17;
+    /**
+     * The name and signature of the console command.
+     * php artisan export:ai.training.data
+     * @var string
+     */
+    protected $signature = 'export:ai.training.data {--format=gz  : zip file format 7z,lzma,gz } {--test}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'export ai training data';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        Log::info('task export offline sentence-table start');
+        //创建文件夹
+        $base = 'app/tmp/export/offline';
+        $exportDir = storage_path($base);
+        if (!is_dir($exportDir)) {
+            $res = mkdir($exportDir, 0755, true);
+            if (!$res) {
+                $this->error('mkdir fail path=' . $exportDir);
+                return 1;
+            } else {
+                $this->info('make dir successful ' . $exportDir);
+            }
+        }
+
+        //创建临时文件夹\
+        $dirname = $exportDir . '/' . 'wikipali-offline-ai-training-' . date("YmdHis");
+
+        $tmp = mkdir($dirname, 0755, true);
+        if (!$tmp) {
+            $this->error('mkdir fail path=' . $dirname);
+            return 1;
+        } else {
+            $this->info('make dir successful ' . $dirname);
+        }
+
+        $fpIndex = fopen($dirname . '/index.md', 'w');
+        if ($fpIndex === false) {
+            die('无法创建索引文件');
+        }
+
+        $channels = [
+            '7ac4d13b-a43d-4409-91b5-5f2a82b916b3',
+            'e5bc5c97-a6fb-4ccb-b7df-be6dcfee9c43',
+            '74ebf4c5-c243-4948-955d-6c277e29276a',
+            '3b0cb0aa-ea88-4ce5-b67d-00a3e76220cc',
+            '5310999c-0b0c-4bb0-9bb9-9cdd176e9ef0',
+            '331447b6-39bb-4b49-ac10-6206db93a050',
+        ];
+
+        $start = time();
+        foreach ($channels as $key => $channel) {
+            if ($this->option('test') && $key > 0) {
+                // test mode 只跑一个
+                break;
+            }
+            fwrite($fpIndex, "# {$channel}\n");
+            $channelInfo = ChannelApi::getById($channel);
+            if ($channelInfo) {
+                fwrite($fpIndex, "- 版本名称:{$channelInfo['name']}\n");
+                fwrite($fpIndex, "- 语言:{$channelInfo['lang']}\n");
+            }
+            // 创建文件
+            $this->info('export start' . $channel);
+            $filename = $channel . '.jsonl';
+            $exportFile = $dirname . '/' . $filename;
+            $fp = fopen($exportFile, 'w');
+            if ($fp === false) {
+                die('无法创建文件');
+            }
+
+            $db = Sentence::where('channel_uid', $channel);
+            $bar = $this->output->createProgressBar($db->count());
+
+            $srcDb = $db->select([
+                'book_id',
+                'paragraph',
+                'word_start',
+                'word_end',
+                'content',
+                'content_type'
+            ])
+                ->whereNotNull('content')
+                ->orderBy('book_id')
+                ->orderBy('paragraph')
+                ->orderBy('word_start')->cursor();
+            $done = [];
+            foreach ($srcDb as $sent) {
+                $id = "{$sent->book_id}-{$sent->paragraph}-{$sent->word_start}-{$sent->word_end}";
+                if (isset($done[$id])) {
+                    continue;
+                }
+                //获取原文
+                $origin = PaliSentence::where('book', $sent->book_id)
+                    ->where('paragraph', $sent->paragraph)
+                    ->where('word_begin', $sent->word_start)
+                    ->where('word_end', $sent->word_end)
+                    ->value('text');
+                //忽略空的原文
+                if (self::isEmpty($origin)) {
+                    Log::warning('origin is empty id=' . $id);
+                    continue;
+                }
+                // 渲染译文
+                $translation = MdRender::render(
+                    $sent->content,
+                    [$channel],
+                    null,
+                    'read',
+                    'translation',
+                    $sent->content_type,
+                    'text',
+                );
+                $translation = trim($translation);
+                // 忽略空的译文
+                if (self::isEmpty($translation)) {
+                    Log::warning('translation is empty id=' . $id);
+                    continue;
+                }
+
+                //忽略过短的译文
+                if (mb_strlen($translation) / mb_strlen($origin) < $this->ShortTrans) {
+                    Log::warning('translation is short id=' . $id);
+                    continue;
+                }
+                //原文与翻译完全相同
+                if ($translation === $origin) {
+                    Log::warning('translation is same id=' . $id);
+                    continue;
+                }
+                // 获取分类标签
+                $paliTextService = app(PaliTextService::class);
+                $tags = $paliTextService->getParaCategoryTags($sent->book_id, $sent->paragraph);
+                $path = $paliTextService->getParaPathTitle($sent->book_id, $sent->paragraph);
+                $currData = [
+                    'id' => $id,
+                    'original' => $origin,
+                    'translation' => $translation,
+                    'category' => $tags,
+                    'path' => $path,
+                ];
+
+                fwrite($fp, json_encode($currData, JSON_UNESCAPED_UNICODE) . "\n");
+                $bar->advance();
+                $done[$id] = 1;
+            }
+            fclose($fp);
+        }
+        fclose($fpIndex);
+
+        $this->info((time() - $start) . ' seconds');
+        $this->call('export:zip2', [
+            'id' => 'ai-translating-training-data',
+            'filename' => $dirname,
+            'title' => 'wikipali ai translating training data',
+            'format' => $this->option('format'),
+        ]);
+
+        sleep(5);
+        File::deleteDirectory($dirname);
+
+        return 0;
+    }
+
+    private function isEmpty(?string $input): bool
+    {
+        if (empty($input)) {
+            return true;
+        }
+        $result = preg_replace('/[\s\d\p{P}]/u', '', $input);
+        return empty($result);
+    }
+}

+ 165 - 0
api-v13/app/Console/Commands/ExportArticle.php

@@ -0,0 +1,165 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Storage;
+
+use Illuminate\Support\Facades\Cache;
+use App\Tools\ExportDownload;
+use App\Http\Api\MdRender;
+
+
+class ExportArticle extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan export:article 78c22ad3-58e2-4cf0-b979-67783ca3a375 123 --channel=7fea264d-7a26-40f8-bef7-bc95102760fb --format=html
+     * php artisan export:article df6c6609-6fc1-42d0-9ef1-535ef3e702c9 1234 --origin=true --channel=7fea264d-7a26-40f8-bef7-bc95102760fb  --format=docx --anthology=697c9169-cb9d-4a60-8848-92745e467bab --token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJuYmYiOjE2OTc3Mjg2ODUsImV4cCI6MTcyOTI2NDY4NSwidWlkIjoiYmE1NDYzZjMtNzJkMS00NDEwLTg1OGUtZWFkZDEwODg0NzEzIiwiaWQiOjR9.fiXhnY2LczZ9kKVHV0FfD3AJPZt-uqM5wrDe4EhToVexdd007ebPFYssZefmchfL0mx9nF0rgHSqjNhx4P0yDA
+     * @var string
+     */
+    protected $signature = 'export:article {id} {query_id} {--token=} {--anthology=} {--channel=}  {--origin=false} {--translation=true} {--format=tex} {--debug}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $this->info('task export chapter start');
+        Log::debug('task export chapter start');
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+        $options = [
+            'queryId' => $this->argument('query_id'),
+            'format' => $this->option('format'),
+            'debug' => $this->option('debug'),
+            'filename' => 'article',
+        ];
+        $upload = new ExportDownload($options);
+
+        MdRender::init();
+        $m = new \Mustache_Engine(array(
+            'entity_flags' => ENT_QUOTES,
+            'delimiters' => '[[ ]]',
+            'escape' => function ($value) {
+                return $value;
+            }
+        ));
+
+        $sections = array();
+        $articles = array();
+
+
+        $article = $this->fetch($this->argument('id'));
+        if (!$article) {
+            return 1;
+        }
+
+        $bookMeta = array();
+        $bookMeta['book_author'] = "";
+        $bookMeta['book_title'] = $article['title_text'];
+
+        $articles[] = [
+            'level' => 1,
+            'title' => $article['title_text'],
+            'content' => isset($article['html']) ? $article['html'] : '',
+        ];
+        $progress = 0.1;
+        $this->info($upload->setStatus($progress, 'export article content title=' . $article['title_text']));
+
+        if (isset($article['toc']) && count($article['toc']) > 0) {
+            $this->info('has sub article ' . count($article['toc']));
+            $step = 0.8 / count($article['toc']);
+            $baseLevel = 0;
+            foreach ($article['toc'] as $key => $value) {
+                if ($baseLevel === 0) {
+                    $baseLevel = $value['level'] - 2;
+                }
+                $progress += $step;
+                $this->info($upload->setStatus($progress, 'exporting article title=' . $value['title']));
+                $article = $this->fetch($value['key']);
+                if (!$article) {
+                    $this->info($upload->setStatus($progress, 'exporting article fail title=' . $value['title']));
+                    continue;
+                }
+                $this->info($upload->setStatus($progress, 'exporting article success title=' . $article['title_text']));
+                $articles[] = [
+                    'level' => $value['level'] - $baseLevel,
+                    'title' => $article['title_text'],
+                    'content' => isset($article['html']) ? $article['html'] : '',
+                ];
+            }
+        }
+
+        $sections[] = [
+            'name' => 'articles',
+            'body' => ['articles' => $articles],
+        ];
+        $this->info($upload->setStatus(0.9, 'export article content done'));
+        Log::debug('导出结束');
+
+
+        $upload->upload('article', $sections, $bookMeta);
+        $this->info($upload->setStatus(1, 'export article done'));
+        return 0;
+    }
+
+    private function fetch($articleId)
+    {
+        $api = config('mint.server.api.bamboo');
+        $basicUrl = $api . '/v2/article/';
+        $url =  $basicUrl . $articleId;;
+        $this->info('http request url=' . $url);
+
+        $urlParam = [
+            'mode' => 'read',
+            'format' => 'markdown',
+            'anthology' => $this->option('anthology'),
+            'channel' => $this->option('channel'),
+            'origin' => 'true' /*$this->option('origin')*/,
+            'paragraph' => true,
+        ];
+
+        Log::debug('export article http request', ['url' => $url, 'param' => $urlParam]);
+        if ($this->option('token')) {
+            $response = Http::withToken($this->option('token'))->get($url, $urlParam);
+        } else {
+            $response = Http::get($url, $urlParam);
+        }
+
+        if ($response->failed()) {
+            $this->error('http request error' . $response->json('message'));
+            Log::error('http request error', ['error' => $response->json('message')]);
+            return false;
+        }
+        if (!$response->json('ok')) {
+            $this->error('http request error' . $response->json('message'));
+            return false;
+        }
+        $article = $response->json('data');
+        return $article;
+    }
+}

+ 87 - 0
api-v13/app/Console/Commands/ExportChannel.php

@@ -0,0 +1,87 @@
+<?php
+/**
+ * 导出离线用的channel数据
+ */
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use App\Models\Channel;
+use Illuminate\Support\Facades\Log;
+
+class ExportChannel extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:channel {db}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '导出离线用的channel数据';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+        Log::debug('task export offline channel-table start');
+        $exportFile = storage_path('app/public/export/offline/'.$this->argument('db').'-'.date("Y-m-d").'.db3');
+        $dbh = new \PDO('sqlite:'.$exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
+        $dbh->beginTransaction();
+
+        $query = "INSERT INTO channel ( id , name , type , language ,
+                                    summary , owner_id , setting,created_at )
+                                    VALUES ( ? , ? , ? , ? , ? , ? , ? , ?  )";
+        try{
+            $stmt = $dbh->prepare($query);
+        }catch(PDOException $e){
+            Log::info($e);
+            return 1;
+        }
+
+        $bar = $this->output->createProgressBar(Channel::where('status',30)->count());
+        foreach (Channel::where('status',30)
+                ->select(['uid','name','type','lang',
+                          'summary','owner_uid','setting','created_at'])
+                          ->cursor() as $row) {
+                $currData = array(
+                            $row->uid,
+                            $row->name,
+                            $row->type,
+                            $row->lang,
+                            $row->summary,
+                            $row->owner_uid,
+                            $row->setting,
+                            $row->created_at,
+                            );
+            $stmt->execute($currData);
+            $bar->advance();
+        }
+        $dbh->commit();
+        $bar->finish();
+        Log::debug('task export offline channel-table finished');
+        return 0;
+    }
+}

+ 314 - 0
api-v13/app/Console/Commands/ExportChapter.php

@@ -0,0 +1,314 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+
+use App\Models\ProgressChapter;
+use App\Models\Channel;
+use App\Models\PaliText;
+use App\Models\Sentence;
+
+use App\Http\Api\ChannelApi;
+use App\Http\Api\MdRender;
+use App\Tools\Export;
+use Illuminate\Support\Facades\Cache;
+use App\Tools\ExportDownload;
+
+
+class ExportChapter extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan export:chapter 213 3 a19eaf75-c63f-4b84-8125-1bce18311e23 213-3.html --format=html --origin=true
+     * php artisan export:chapter 168 915 7fea264d-7a26-40f8-bef7-bc95102760fb 168-915.html --format=html --debug
+     * php artisan export:chapter 168 915 7fea264d-7a26-40f8-bef7-bc95102760fb 168-915.html --format=html --origin=true
+     * @var string
+     */
+    protected $signature = 'export:chapter {book} {para} {channel} {query_id} {--token=} {--origin=false} {--translation=true} {--debug} {--format=markdown} ';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $this->info('task export chapter start');
+        Log::debug('task export chapter start');
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+        $book = $this->argument('book');
+        $para = $this->argument('para');
+
+        $upload = new ExportDownload([
+            'queryId' => $this->argument('query_id'),
+            'format' => $this->option('format'),
+            'debug' => $this->option('debug'),
+            'filename' => $book . '-' . $para,
+        ]);
+
+        $m = new \Mustache_Engine(array(
+            'entity_flags' => ENT_QUOTES,
+            'delimiters' => '[[ ]]',
+            'escape' => function ($value) {
+                return $value;
+            }
+        ));
+        $tplFile = resource_path("mustache/chapter/md/paragraph.md");
+        $tplParagraph = file_get_contents($tplFile);
+
+        MdRender::init();
+
+        $renderFormat = 'markdown';
+
+        //获取原文channel
+        $orgChannelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
+
+        $tranChannelsId = explode('_', $this->argument('channel'));
+
+        $channelsId = array_merge([$orgChannelId], $tranChannelsId);
+
+        $channels = array();
+        $channelsIndex = array();
+        foreach ($channelsId as $key => $id) {
+            $channels[] = ChannelApi::getById($id);
+            $channelsIndex[$id] = ChannelApi::getById($id);
+        }
+
+        $bookMeta = array();
+        $bookMeta['book_author'] = "";
+        foreach ($channels as $key => $channel) {
+            $bookMeta['book_author'] .= $channel['name'] . ' ';
+        }
+
+        $chapter = PaliText::where('book', $book)
+            ->where('paragraph', $para)->first();
+        if (!$chapter) {
+            return $this->error("no data");
+        }
+
+        $currProgress = 0;
+        $this->info($upload->setStatus($currProgress, 'start'));
+
+
+        if (empty($chapter->toc)) {
+            $bookMeta['title'] = "unknown";
+        } else {
+            $bookMeta['book_title'] = '';
+            foreach ($channelsId as $key => $id) {
+                $title = ProgressChapter::where('book', $book)->where('para', $para)
+                    ->where('channel_id', $id)
+                    ->value('title');
+                $bookMeta['book_title'] .= $title;
+            }
+            $bookMeta['sub_title'] = $chapter->toc;
+        }
+
+        $subChapter = PaliText::where('book', $book)->where('parent', $para)
+            ->where('level', '<', 8)
+            ->orderBy('paragraph')
+            ->get();
+        if (count($subChapter) === 0) {
+            //没有子章节
+            $subChapter = PaliText::where('book', $book)->where('paragraph', $para)
+                ->where('level', '<', 8)
+                ->orderBy('paragraph')
+                ->get();
+        }
+
+        $chapterParagraph = PaliText::where('book', $book)->where('paragraph', $para)->value('chapter_len');
+        if ($chapterParagraph > 0) {
+            $step = 0.9 / $chapterParagraph;
+        } else {
+            $step = 0.9;
+            Log::error('段落长度不能为0', ['book' => $book, 'para' => $para]);
+        }
+
+        $outputChannelsId = [];
+        if ($this->option('origin') === 'true') {
+            $outputChannelsId[] = $orgChannelId;
+        }
+        if ($this->option('translation') === 'true') {
+            $outputChannelsId = array_merge($outputChannelsId, $tranChannelsId);
+        }
+
+        $sections = array();
+        foreach ($subChapter as $key => $sub) {
+            # 看这个章节是否存在译文
+            $hasChapter = false;
+            if ($this->option('origin') === 'true') {
+                $hasChapter = true;
+            }
+            if ($this->option('translation') === 'true') {
+                foreach ($tranChannelsId as $id) {
+                    if (ProgressChapter::where('book', $book)->where('para', $sub->paragraph)
+                        ->where('channel_id', $id)
+                        ->exists()
+                    ) {
+                        $hasChapter = true;
+                    }
+                }
+            }
+            if (!$hasChapter) {
+                //不存在需要导出的数据
+                continue;
+            }
+            $filename = "{$sub->paragraph}." . $this->option('format');
+            $bookMeta['sections'][] = ['filename' => $filename];
+            $paliTitle = PaliText::where('book', $book)
+                ->where('paragraph', $sub->paragraph)
+                ->value('toc');
+            $sectionTitle = $paliTitle;
+            if ($this->option('translation') === 'true') {
+                $chapter = ProgressChapter::where('book', $book)->where('para', $sub->paragraph)
+                    ->where('channel_id', $tranChannelsId[0])
+                    ->first();
+                if ($chapter && !empty($chapter->title)) {
+                    $sectionTitle = $chapter->title;
+                }
+            }
+
+
+            $content = array();
+
+            $chapterStart = $sub->paragraph + 1;
+            $chapterEnd = $sub->paragraph + $sub->chapter_len;
+            $chapterBody = PaliText::where('book', $book)
+                ->whereBetween('paragraph', [$chapterStart, $chapterEnd])
+                ->orderBy('paragraph')->get();
+
+
+
+            foreach ($chapterBody as $body) {
+                $currProgress += $step;
+                $this->info($upload->setStatus($currProgress, 'export chapter ' . $body->paragraph));
+                $paraData = array();
+                $paraData['translations'] = array();
+                foreach ($outputChannelsId as $key => $channelId) {
+                    $translationData = Sentence::where('book_id', $book)
+                        ->where('paragraph', $body->paragraph)
+                        ->where('channel_uid', $channelId)
+                        ->orderBy('word_start')->get();
+                    $sentContent = array();
+                    foreach ($translationData as $sent) {
+                        $texText = MdRender::render(
+                            $sent->content,
+                            [$sent->channel_uid],
+                            null,
+                            'read',
+                            $channelsIndex[$channelId]['type'],
+                            $sent->content_type,
+                            $renderFormat
+                        );
+                        $sentContent[] = trim($texText);
+                    }
+                    $paraContent = implode(' ', $sentContent);
+                    if ($channelsIndex[$channelId]['type'] === 'original') {
+                        $paraData['origin'] = $paraContent;
+                    } else {
+                        $paraData['translations'][] = ['content' => $paraContent];
+                    }
+                }
+                if ($body->level > 7) {
+                    $content[] = $m->render($tplParagraph, $paraData);
+                } else {
+                    $currLevel = $body->level - $sub->level;
+                    if ($currLevel <= 0) {
+                        $currLevel = 1;
+                    }
+
+                    if (count($paraData['translations']) === 0) {
+                        $subSessionTitle = PaliText::where('book', $book)
+                            ->where('paragraph', $body->paragraph)
+                            ->value('toc');
+                    } else {
+                        $subSessionTitle = $paraData['translations'][0]['content'];
+                    }
+
+                    //标题
+                    $subStr = array_fill(0, $currLevel, '#');
+                    $content[] = implode('', $subStr) . " " . $subSessionTitle;
+                }
+                $content[] = "\n\n";
+            }
+
+            $sections[] = [
+                'name' => $filename,
+                'body' => [
+                    'title' => $sectionTitle,
+                    'content' => implode('', $content)
+                ]
+            ];
+        }
+
+        //导出术语表
+        $keyPali = array();
+        $keyMeaning = array();
+        if (isset($GLOBALS['glossary'])) {
+            $glossary = $GLOBALS['glossary'];
+            foreach ($glossary as $word => $meaning) {
+                $keyMeaning[$meaning] = $word;
+                $keyPali[$word] = $meaning;
+            }
+        }
+
+        ksort($keyPali);
+        krsort($keyMeaning);
+        $glossaryData = [];
+        $glossaryData['pali'] = [];
+        $glossaryData['meaning'] = [];
+        foreach ($keyPali as $word => $meaning) {
+            $glossaryData['pali'][] = ['pali' => $word, 'meaning' => $meaning];
+        }
+        foreach ($keyMeaning as $meaning => $word) {
+            $glossaryData['meaning'][] = ['pali' => $word, 'meaning' => $meaning];
+        }
+
+        Log::debug('glossary', ['data' => $glossaryData]);
+
+        $tplFile = resource_path("mustache/chapter/" . $this->option('format') . "/glossary." . $this->option('format'));
+        $tplGlossary = file_get_contents($tplFile);
+
+        $glossaryContent = $m->render($tplGlossary, $glossaryData);
+
+        $sections[] = [
+            'name' => 'glossary.' . $this->option('format'),
+            'body' => [
+                'title' => 'glossary',
+                'content' => $glossaryContent
+            ]
+        ];
+        $this->info($upload->setStatus($currProgress, 'export glossary ' . count($keyPali)));
+
+        $this->info($upload->setStatus(0.9, 'export content done sections=' . count($sections)));
+
+        Log::debug('导出结束', ['sections' => count($sections)]);
+
+        $upload->upload('chapter', $sections, $bookMeta);
+        $this->info($upload->setStatus(1, 'export chapter done'));
+
+        return 0;
+    }
+}

+ 100 - 0
api-v13/app/Console/Commands/ExportChapterIndex.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use App\Models\ProgressChapter;
+use App\Models\Channel;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
+
+class ExportChapterIndex extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:chapter.index {db : db file name wikipali-offline or wikipali-offline-index}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'export chapter index';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        Log::debug('task export offline chapter-index-table start');
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+
+        $exportFile = storage_path('app/public/export/offline/' . $this->argument('db') . '-' . date("Y-m-d") . '.db3');
+        $dbh = new \PDO('sqlite:' . $exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
+        $dbh->beginTransaction();
+
+        $query = "INSERT INTO chapter ( id , book , paragraph,
+                                    language , title , channel_id , progress,updated_at  )
+                                    VALUES ( ? , ? , ? , ? , ? , ? , ? , ?  )";
+        try {
+            $stmt = $dbh->prepare($query);
+        } catch (\PDOException $e) {
+            Log::info($e);
+            return 1;
+        }
+
+        $publicChannels = Channel::where('status', 30)->select('uid')->get();
+        $rows = ProgressChapter::whereIn('channel_id', $publicChannels)->count();
+        Cache::put("/export/chapter/count", $rows, 3600 * 10);
+        $bar = $this->output->createProgressBar($rows);
+        foreach (
+            ProgressChapter::whereIn('channel_id', $publicChannels)
+                ->select([
+                    'uid',
+                    'book',
+                    'para',
+                    'lang',
+                    'title',
+                    'channel_id',
+                    'progress',
+                    'updated_at'
+                ])->cursor() as $row
+        ) {
+            $currData = array(
+                $row->uid,
+                $row->book,
+                $row->para,
+                $row->lang,
+                $row->title,
+                $row->channel_id,
+                $row->progress,
+                $row->updated_at,
+            );
+            $stmt->execute($currData);
+            $bar->advance();
+        }
+        $dbh->commit();
+        $bar->finish();
+        Log::debug('task export offline chapter-index-table finished');
+        return 0;
+    }
+}

+ 69 - 0
api-v13/app/Console/Commands/ExportCreateDb.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Log;
+
+class ExportCreateDb extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:create.db';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        Log::debug('task export offline create-db start');
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+        $this->create('sentence.sql','wikipali-offline');
+        $this->create('sentence.sql','wikipali-offline-index');
+
+        return 0;
+    }
+
+    private function create($sqlFile,$dbFile){
+        $sqlPath = database_path('export/'.$sqlFile);
+        $exportDir = storage_path('app/public/export/offline');
+        $exportFile = $exportDir.'/'.$dbFile.'-'.date("Y-m-d").'.db3';
+        $file = fopen($exportFile,'w');
+        fclose($file);
+        $dbh = new \PDO('sqlite:'.$exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
+        //建立数据库
+        $_sql = file_get_contents($sqlPath);
+        $_arr = explode(';', $_sql);
+        //执行sql语句
+        foreach ($_arr as $_value) {
+            $dbh->query($_value . ';');
+        }
+        Log::debug('task export offline create-db finished');
+    }
+}

+ 259 - 0
api-v13/app/Console/Commands/ExportDiscussion.php

@@ -0,0 +1,259 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use App\Http\Api\ChannelApi;
+use Carbon\Carbon;
+
+class ExportDiscussion extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan export:discussion
+     */
+    protected $signature = 'export:discussion {editor : The editor UID to export discussions for}';
+
+    /**
+     * The console command description.
+     */
+    protected $description = 'Export discussions made by a specific editor to a Markdown file';
+
+    /** @var string 巴利原文 channel_uid */
+    private string $orgChannelId;
+
+    /** @var resource 输出文件句柄(流式写入,避免大字符串堆积在内存) */
+    private $fileHandle;
+
+    /** @var string 输出文件路径 */
+    private string $outputPath;
+
+    /**
+     * Execute the console command.
+     */
+    public function handle(): int
+    {
+        $editorUid = $this->argument('editor');
+
+        $this->info("Fetching discussions for editor: {$editorUid}");
+
+        // 1. 获取巴利原文 channel_uid
+        $this->orgChannelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
+        if (!$this->orgChannelId) {
+            $this->error('Failed to retrieve Pali source channel ID.');
+            return self::FAILURE;
+        }
+        $this->info("Pali channel ID: {$this->orgChannelId}");
+
+        // 2. 统计总数(用于进度条)
+        $total = DB::table('discussions')
+            ->where('editor_uid', $editorUid)
+            ->where('status', 'active')
+            ->count();
+
+        if ($total === 0) {
+            $this->warn("No discussions found for editor: {$editorUid}");
+            return self::SUCCESS;
+        }
+
+        $this->info("Found {$total} discussion(s). Processing...");
+
+        // 3. 打开文件句柄(流式写入,不在内存中拼接整个 Markdown)
+        $filename = "discussion_export_{$editorUid}_" . now()->format('YmdHis') . '.md';
+        $this->outputPath = storage_path("app/tmp/{$filename}");
+        $this->fileHandle = fopen($this->outputPath, 'w');
+        if (!$this->fileHandle) {
+            $this->error("Cannot open file for writing: {$this->outputPath}");
+            return self::FAILURE;
+        }
+
+        // 写文件头
+        $this->writeLine("# 讨论导出报告\n");
+        $this->writeLine("- **Editor UID**: {$editorUid}");
+        $this->writeLine("- **导出时间**: " . now()->toDateTimeString());
+        $this->writeLine("\n---\n");
+
+        // 4. 分批处理(每批 50 条),避免内存溢出
+        $progressBar = $this->output->createProgressBar($total);
+        $progressBar->start();
+
+        DB::table('discussions')
+            ->where('editor_uid', $editorUid)
+            ->where('status', 'active')
+            ->orderBy('created_at', 'asc')
+            ->select(['id', 'res_id', 'res_type', 'content', 'created_at'])
+            ->chunk(50, function ($discussions) use ($progressBar) {
+                $this->processChunk($discussions);
+                $progressBar->advance($discussions->count());
+
+                // 每批处理完后主动释放内存
+                gc_collect_cycles();
+            });
+
+        $progressBar->finish();
+        $this->newLine();
+
+        fclose($this->fileHandle);
+
+        $this->info("\n✅ 导出完成!文件已保存到: {$this->outputPath}");
+
+        return self::SUCCESS;
+    }
+
+    /**
+     * 处理一批 discussions。
+     */
+    private function processChunk(\Illuminate\Support\Collection $discussions): void
+    {
+        // --- 批量查译文 sentences ---
+        $resIds = $discussions->pluck('res_id')->unique()->values()->all();
+
+        $translationMap = DB::table('sentences')
+            ->whereIn('uid', $resIds)
+            ->select([
+                'uid',
+                'book_id',
+                'paragraph',
+                'word_start',
+                'word_end',
+                'content',
+                'channel_uid',
+            ])
+            ->get()
+            ->keyBy('uid');
+
+        // --- 批量查 sent_histories(分小批,避免超大 IN) ---
+        $historiesMap = [];
+        foreach (array_chunk($resIds, 100) as $batch) {
+            DB::table('sent_histories')
+                ->whereIn('sent_uid', $batch)
+                ->orderBy('create_time', 'asc')
+                ->select(['sent_uid', 'content', 'create_time'])
+                ->each(function ($row) use (&$historiesMap) {
+                    $historiesMap[$row->sent_uid][] = $row;
+                });
+        }
+
+        // --- 收集本批所有唯一坐标,批量查巴利原文 ---
+        $coordKeys = [];
+        foreach ($translationMap as $t) {
+            $key = "{$t->book_id}_{$t->paragraph}_{$t->word_start}_{$t->word_end}";
+            $coordKeys[$key] = $t;
+        }
+        $paliMap = $this->fetchPaliSentences($coordKeys);
+
+        // --- 写 Markdown ---
+        foreach ($discussions as $discussion) {
+            $sentUid     = $discussion->res_id;
+            $translation = $translationMap->get($sentUid);
+            if (!$translation) {
+                continue;
+            }
+
+            $coordKey    = "{$translation->book_id}_{$translation->paragraph}_{$translation->word_start}_{$translation->word_end}";
+            $pali        = $paliMap[$coordKey] ?? null;
+            $paliContent = $pali ? trim($pali->content ?? '(无原文)') : '(未找到巴利原文)';
+
+            $discussionCreatedAt = $discussion->created_at
+                ? Carbon::parse($discussion->created_at)
+                : null;
+
+            $histories      = $historiesMap[$sentUid] ?? [];
+            $matchedHistory = $this->findClosestHistory($histories, $discussionCreatedAt);
+            $translationAtTime = $matchedHistory
+                ? trim($matchedHistory->content)
+                : trim($translation->content ?? '(无译文内容)');
+
+            $this->writeLine("# {$paliContent}\n");
+            $this->writeLine("  - **历史译文**: {$translationAtTime}");
+            $this->writeLine("  - **评论**: " . trim($discussion->title ?? '') . trim($discussion->content ?? ''));
+            $this->writeLine("  - **当前译文**: {$translation->content}");
+            $this->writeLine('');
+        }
+
+        // 显式释放本批数据
+        unset($translationMap, $historiesMap, $coordKeys, $paliMap);
+    }
+
+    /**
+     * 批量查询巴利原文,每组最多 30 个坐标,避免超大 SQL。
+     *
+     * @param array<string, object> $coordKeys  key="{book_id}_{paragraph}_{word_start}_{word_end}"
+     * @return array<string, object>
+     */
+    private function fetchPaliSentences(array $coordKeys): array
+    {
+        $paliMap = [];
+
+        foreach (array_chunk(array_values($coordKeys), 30) as $group) {
+            $results = DB::table('sentences')
+                ->where('channel_uid', $this->orgChannelId)
+                ->where(function ($q) use ($group) {
+                    foreach ($group as $t) {
+                        $q->orWhere(function ($sub) use ($t) {
+                            $sub->where('book_id',    $t->book_id)
+                                ->where('paragraph',  $t->paragraph)
+                                ->where('word_start', $t->word_start)
+                                ->where('word_end',   $t->word_end);
+                        });
+                    }
+                })
+                ->select(['book_id', 'paragraph', 'word_start', 'word_end', 'content'])
+                ->get();
+
+            foreach ($results as $ps) {
+                $key = "{$ps->book_id}_{$ps->paragraph}_{$ps->word_start}_{$ps->word_end}";
+                $paliMap[$key] = $ps;
+            }
+
+            unset($results);
+        }
+
+        return $paliMap;
+    }
+
+    /**
+     * 流式写入一行到文件。
+     */
+    private function writeLine(string $line): void
+    {
+        fwrite($this->fileHandle, $line . "\n");
+    }
+
+    /**
+     * 在历史记录中找评论发布时间之前最近的那条。
+     * 若全部在评论之后,则退而取最早一条。
+     *
+     * @param array       $histories           sent_histories(已按 create_time ASC 排序)
+     * @param Carbon|null $discussionCreatedAt 评论发布时间
+     */
+    private function findClosestHistory(array $histories, ?Carbon $discussionCreatedAt): ?object
+    {
+        if (empty($histories)) {
+            return null;
+        }
+
+        if (!$discussionCreatedAt) {
+            return end($histories) ?: null;
+        }
+
+        $discussionTimestamp = $discussionCreatedAt->timestamp;
+        $best     = null;
+        $bestDiff = PHP_INT_MAX;
+
+        foreach ($histories as $h) {
+            $historyTime = (int) $h->create_time;
+            if ($historyTime <= $discussionTimestamp) {
+                $diff = $discussionTimestamp - $historyTime;
+                if ($diff < $bestDiff) {
+                    $bestDiff = $diff;
+                    $best     = $h;
+                }
+            }
+        }
+
+        // 所有历史都在评论之后 → 取最早一条
+        return $best ?? $histories[0];
+    }
+}

+ 109 - 0
api-v13/app/Console/Commands/ExportFtsPali.php

@@ -0,0 +1,109 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\DictApi;
+use App\Models\UserDict;
+use Illuminate\Support\Facades\Redis;
+use Illuminate\Support\Facades\Log;
+
+class ExportFtsPali extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:fts.pali';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '导出全文搜索用的巴利语词汇表';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        //irregular
+        $dictId = ['4d3a0d92-0adc-4052-80f5-512a2603d0e8'];
+        //regular
+        $dictId[] = DictApi::getSysDict('system_regular');
+        $long = ["ā", "ī", "ū"];
+        $path = storage_path('app/export/fts');
+        if (!is_dir($path)) {
+            $res = mkdir($path, 0700, true);
+            if (!$res) {
+                Log::error('mkdir fail path=' . $path);
+                return 1;
+            }
+        }
+
+        $pageSize = 10000;
+        $currPage = 1;
+        $filename = "/pali-{$currPage}.syn";
+        $fp = fopen($path . $filename, 'w') or die("Unable to open file!");
+        $count = 0;
+        foreach ($dictId as $key => $value) {
+            $words = UserDict::where('dict_id', $value)
+                ->select('word')
+                ->groupBy('word')->cursor();
+            $this->info('word count=' . count($words));
+            foreach ($words as $key => $word) {
+                $count++;
+                if ($count % 1000 === 0) {
+                    $this->info($count);
+                }
+                if ($count % 10000 === 0) {
+                    fclose($fp);
+                    $redisKey = 'export/fts/pali' . $filename;
+                    $content = file_get_contents($path . $filename);
+                    Redis::set($redisKey, $content);
+                    Redis::expire($redisKey, 3600 * 24 * 10);
+                    $currPage++;
+                    $filename = "/pali-{$currPage}.syn";
+                    $this->info('new file filename=' . $filename);
+                    $fp = fopen($path . $filename, 'w') or die("Unable to open file!");
+                }
+                $parent = UserDict::where('dict_id', $value)
+                    ->where('word', $word->word)
+                    ->selectRaw('parent,char_length("parent")')
+                    ->groupBy('parent')->orderBy('char_length', 'asc')->first();
+
+                if ($parent && !empty($parent->parent)) {
+                    $end = mb_substr($parent->parent, -1, null, "UTF-8");
+                    if (in_array($end, ["ā", "ī", "ū"])) {
+                        $head = mb_substr($parent->parent, 0, mb_strlen($parent->parent) - 1, "UTF-8");
+                        $newEnd = str_replace(["ā", "ī", "ū"], ["a", "i", "u"], $end);
+                        $parentWord = $head . $newEnd;
+                    } else {
+                        $parentWord = $parent->parent;
+                    }
+                    fwrite($fp, $word->word . ' ' . $parentWord . PHP_EOL);
+                } else {
+                    $this->error('word no parent word=' . $word->word);
+                }
+            }
+        }
+        fclose($fp);
+
+
+        return 0;
+    }
+}

+ 102 - 0
api-v13/app/Console/Commands/ExportGlossary.php

@@ -0,0 +1,102 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\File;
+use App\Services\TermService;
+
+
+class ExportGlossary extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan export:export-glossary zh-Hans
+     * @var string
+     */
+    protected $signature = 'export:export-glossary {lang}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '导出术语表';
+
+    protected TermService $termService;
+
+    public function __construct(TermService $termService)
+    {
+        $this->termService = $termService;
+        parent::__construct();
+    }
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        Log::info('task export offline sentence-table start');
+        $lang = $this->argument('lang');
+        //创建文件夹
+        $base = 'app/tmp/export/offline';
+        $exportDir = storage_path($base);
+        if (!is_dir($exportDir)) {
+            $res = mkdir($exportDir, 0755, true);
+            if (!$res) {
+                $this->error('mkdir fail path=' . $exportDir);
+                return 1;
+            } else {
+                $this->info('make dir successful ' . $exportDir);
+            }
+        }
+
+        //创建临时文件夹\
+        $dirname = $exportDir . '/' . 'wikipali_glossary_' . date("YmdHis");
+
+        $tmp = mkdir($dirname, 0755, true);
+        if (!$tmp) {
+            $this->error('mkdir fail path=' . $dirname);
+            return 1;
+        } else {
+            $this->info('make dir successful ' . $dirname);
+        }
+
+        $fpIndex = fopen($dirname . '/index.md', 'w');
+        if ($fpIndex === false) {
+            die('无法创建索引文件');
+        }
+
+        // 创建json文件
+        $this->info('export start' . $lang);
+        $filename = 'glossary_' . $lang . '.jsonl';
+        $exportFile = $dirname . '/' . $filename;
+        $fp = fopen($exportFile, 'w');
+        if ($fp === false) {
+            die('无法创建文件');
+        }
+        $start = time();
+
+        //**业务逻辑 */
+
+        $data = $this->termService->getCommunityGlossary($lang);
+        foreach ($data['items'] as $key => $value) {
+            fwrite($fp, json_encode($value, JSON_UNESCAPED_UNICODE) . "\n");
+        }
+
+        fclose($fpIndex);
+
+        $this->info((time() - $start) . ' seconds');
+        $this->call('export:zip2', [
+            'id' => 'wikipali_glossary',
+            'filename' => $dirname,
+            'title' => 'wikipali glossary of community',
+            'format' => 'jsonl',
+        ]);
+
+        sleep(5);
+        File::deleteDirectory($dirname);
+
+        return 0;
+    }
+}

+ 68 - 0
api-v13/app/Console/Commands/ExportIKPaliTeam.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\DhammaTerm;
+use Illuminate\Support\Facades\Redis;
+use Illuminate\Support\Facades\Log;
+
+class ExportIKPaliTeam extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan export:ik.pali.team
+     * @var string
+     */
+    protected $signature = 'export:ik.pali.team';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $path = storage_path('app/export/fts');
+        if (!is_dir($path)) {
+            $res = mkdir($path, 0700, true);
+            if (!$res) {
+                Log::error('mkdir fail path=' . $path);
+                return 1;
+            }
+        }
+        $filename = "/pali_term.txt";
+        $fp = fopen($path . $filename, 'w') or die("Unable to open file!");
+        $wordsList = [];
+        $teams = DhammaTerm::select(['meaning', 'other_meaning'])->get();
+        foreach ($teams as $term) {
+            if (!empty($term->meaning)) {
+                $wordsList[$term->meaning] = 1;
+            }
+        }
+        foreach ($wordsList as $word => $value) {
+            fwrite($fp, $word . PHP_EOL);
+        }
+        // 关闭文件
+        fclose($fp);
+        $this->info('done');
+        return 0;
+    }
+}

+ 219 - 0
api-v13/app/Console/Commands/ExportNissaya.php

@@ -0,0 +1,219 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use App\Models\Channel;
+use App\Models\Sentence;
+use App\Models\NissayaEnding;
+use App\Models\UserDict;
+use App\Http\Api\DictApi;
+
+class SuttaType
+{
+    public static function types(){
+        return     [
+            'mula'=>[
+                69,70,71,72,73,74,
+                75,76,77,78,79,80,
+                81,82,83,84,85,86,
+                87,88,89,90,91,92,
+                93,94,95,143,144,145,
+                146,147,148,149,150,151,
+                152,153,154,155,156,157,
+                158,159,160,161,162,163,
+                164,165,166,167,168,169,
+                170,171,213,214,215,216,217,
+            ],
+            'atthakatha' => [
+                64,65,96,97,98,99,
+                100,101,102,103,104,105,
+                106,107,108,109,110,111,
+                112,113,114,115,116,117,
+                118,119,120,121,122,123,
+                124,125,126,127,128,129,
+                130,131,132,133,134,135,
+                136,137,138,139,140,141,142,
+            ],
+            'tika' => [
+                66,67,68,172,173,174,
+                175,176,177,178,179,180,
+                181,182,183,184,185,186,
+                187,188,189,190,191,192,
+                193,194,195,196,197,198,
+                199,200,201,202,203,204,
+                205,206,207,208,209,210,211,212,
+            ],
+            'vinaya' => [138,139,140,141,142,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,],
+            'sutta' => [
+                82,83,84,85,86,
+                87,88,89,90,91,92,93,
+                94,95,99,100,101,102,
+                103,104,105,106,107,108,
+                109,110,111,112,113,114,
+                115,116,117,118,119,120,
+                121,122,123,124,125,126,
+                127,128,129,130,131,132,
+                133,134,135,136,137,143,
+                144,145,146,147,148,149,
+                150,151,152,153,154,155,
+                156,157,158,159,160,161,
+                162,163,164,165,166,167,
+                168,169,170,171,181,182,
+                183,184,185,186,187,188,
+                189,190,191,192,193,194,
+                195,196,197,198,199,
+            ],
+            'abhidhamma' => [69,70,71,72,73,74,75,76,77,78,79,80,81,96,97,98,172,173,174,175,176,177,178,179,180,],
+        ];
+    }
+
+    public static function getTypeByBook($bookId){
+        $types = [];
+        foreach (SuttaType::types() as $type => $books) {
+            if(in_array($bookId,$books)){
+                $types[] = $type;
+            }
+        }
+        return $types;
+    }
+}
+
+class ExportNissaya extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan export:nissaya
+     * @var string
+     */
+    protected $signature = 'export:nissaya';
+    protected $my = ["ႁႏၵ","ခ္","ဃ္","ဆ္","ဈ္","ည္","ဌ္","ဎ္","ထ္","ဓ္","ဖ္","ဘ္","က္","ဂ္","စ္","ဇ္","ဉ္","ဠ္","ဋ္","ဍ္","ဏ္","တ္","ဒ္","န္","ဟ္","ပ္","ဗ္","မ္","ယ္","ရ္","လ္","ဝ္","သ္","င္","င်္","ဿ","ခ","ဃ","ဆ","ဈ","စျ","ည","ဌ","ဎ","ထ","ဓ","ဖ","ဘ","က","ဂ","စ","ဇ","ဉ","ဠ","ဋ","ဍ","ဏ","တ","ဒ","န","ဟ","ပ","ဗ","မ","ယ","ရ","႐","လ","ဝ","သ","aျ္","aွ္","aြ္","aြ","ၱ","ၳ","ၵ","ၶ","ၬ","ၭ","ၠ","ၡ","ၢ","ၣ","ၸ","ၹ","ၺ","႓","ၥ","ၧ","ၨ","ၩ","်","ျ","ႅ","ၼ","ွ","ႇ","ႆ","ၷ","ၲ","႒","႗","ၯ","ၮ","႑","kaၤ","gaၤ","khaၤ","ghaၤ","aှ","aိံ","aုံ","aော","aေါ","aအံ","aဣံ","aဥံ","aံ","aာ","aါ","aိ","aီ","aု","aဳ","aူ","aေ","အါ","အာ","အ","ဣ","ဤ","ဥ","ဦ","ဧ","ဩ","ႏ","ၪ","a္","္","aံ","ေss","ေkh","ေgh","ေch","ေjh","ေññ","ေṭh","ေḍh","ေth","ေdh","ေph","ေbh","ေk","ေg","ေc","ေj","ေñ","ေḷ","ေṭ","ေḍ","ေṇ","ေt","ေd","ေn","ေh","ေp","ေb","ေm","ေy","ေr","ေl","ေv","ေs","ေy","ေv","ေr","ea","eā","၁","၂","၃","၄","၅","၆","၇","၈","၉","၀","း","့","။","၊"];
+    protected $en = ["ndra","kh","gh","ch","jh","ññ","ṭh","ḍh","th","dh","ph","bh","k","g","c","j","ñ","ḷ","ṭ","ḍ","ṇ","t","d","n","h","p","b","m","y","r","l","v","s","ṅ","ṅ","ssa","kha","gha","cha","jha","jha","ñña","ṭha","ḍha","tha","dha","pha","bha","ka","ga","ca","ja","ña","ḷa","ṭa","ḍa","ṇa","ta","da","na","ha","pa","ba","ma","ya","ra","ra","la","va","sa","ya","va","ra","ra","္ta","္tha","္da","္dha","္ṭa","္ṭha","္ka","္kha","္ga","္gha","္pa","္pha","္ba","္bha","္ca","္cha","္ja","္jha","္a","္ya","္la","္ma","္va","္ha","ssa","na","ta","ṭṭha","ṭṭa","ḍḍha","ḍḍa","ṇḍa","ṅka","ṅga","ṅkha","ṅgha","ha","iṃ","uṃ","o","o","aṃ","iṃ","uṃ","aṃ","ā","ā","i","ī","u","u","ū","e","ā","ā","a","i","ī","u","ū","e","o","n","ñ","","","aṃ","sse","khe","ghe","che","jhe","ññe","ṭhe","ḍhe","the","dhe","phe","bhe","ke","ge","ce","je","ñe","ḷe","ṭe","ḍe","ṇe","te","de","ne","he","pe","be","me","ye","re","le","ve","se","ye","ve","re","e","o","1","2","3","4","5","6","7","8","9","0","”","’",".",","];
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '导出nissaya统计数据';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+        $nissaya_channels = Channel::where('type','nissaya')
+                            ->where('lang','my')
+                            ->select('uid')->get();
+        $this->info('channel:'.count($nissaya_channels));
+        //system regular
+        $dict_id = DictApi::getSysDict('system_regular');
+        if(!$dict_id){
+            $this->error('没有找到 system_regular 字典');
+            return 1;
+        }else{
+            $this->info("system_regular :{$dict_id}");
+        }
+
+        //获取缅文语尾表
+        $nissayaEndings = NissayaEnding::select('ending')->groupBy('ending')->get();
+        $endings = [];
+        $maxLen = 0;
+        foreach ($nissayaEndings as $key => $ending) {
+            $endings[] = $ending->ending;
+            if(mb_strlen($ending->ending,'UTF-8')>$maxLen){
+                $maxLen = mb_strlen($ending->ending,'UTF-8');
+            }
+        }
+        $this->info(count($endings).' ending');
+
+        $filename = "public/export/nissaya.csv";
+        Storage::disk('local')->put($filename, "");
+        $file = fopen(storage_path("app/$filename"),"w");
+        $bar = $this->output->createProgressBar(Sentence::whereIn('channel_uid',$nissaya_channels)->count());
+        foreach (Sentence::whereIn('channel_uid',$nissaya_channels)->select(['content','book_id'])->cursor() as $sent) {
+            $lines = explode("\n",$sent->content);
+            foreach ($lines as $key => $line) {
+                # code...
+                if(substr_count(trim($line),'=') === 1){
+                    $nissaya_str = explode('=',$line);
+                    $pali = $this->my2en($nissaya_str[0]);
+                    $types = SuttaType::getTypeByBook($sent->book_id);
+                    $strTypes = implode(",",$types);
+                    //拆分
+                    $factors = UserDict::where('dict_id',$dict_id)->where('word',$pali)->value('factors');
+                    $factors = explode('+',$factors);
+                    if(count($factors)>1){
+                        $paliEnding = end($factors);
+                    }else{
+                        $paliEnding = '';
+                    }
+                    $nissaya_my = trim($nissaya_str[1]);
+                    $mEnding1 = $this->matchEnding($nissaya_my,$endings,$maxLen);
+                    if(!empty($paliEnding) && !empty($mEnding1[1])){
+                        $mixed = $paliEnding.$mEnding1[1];
+                        fputcsv($file,[$strTypes, $pali,$paliEnding,$nissaya_my,$mEnding1[1],$mixed]);
+                    }
+                    $mEnding2= ['',''];
+                    if(!empty($mEnding1[1])){
+                        $mEnding2 = $this->matchEnding($mEnding1[0],$endings,$maxLen);
+                        if(!empty($paliEnding) && !empty($mEnding2[1])){
+                            $mixed = $paliEnding.$mEnding2[1];
+                            fputcsv($file,[$strTypes, $pali,$paliEnding,$nissaya_my,$mEnding2[1],$mixed]);
+                        }
+                    }
+                    $mEnding3= ['',''];
+                    if(!empty($mEnding2[1])){
+                        $mEnding3 = $this->matchEnding($mEnding2[0],$endings,$maxLen);
+                        if(!empty($paliEnding) && !empty($mEnding3[1])){
+                            $mixed = $paliEnding.$mEnding3[1];
+                            fputcsv($file,[$strTypes, $pali,$paliEnding,$nissaya_my,$mEnding3[1],$mixed]);
+                        }
+                    }
+
+                    //fputcsv($file,[$strTypes, $pali,$paliEnding,$nissaya_my,$mEnding1[1],$mEnding2[1],$mEnding3[1]]);
+                }
+            }
+            $bar->advance();
+        }
+        fclose($file);
+        $bar->finish();
+        $this->info('done');
+        $this->info($filename);
+        return 0;
+    }
+
+    public function my2en($my){
+        return str_replace($this->my,$this->en,$my);
+    }
+
+    private function matchEnding($needle,$endings,$maxLen){
+        $needle = trim($needle);
+        if(mb_substr($needle,-1,1,'UTF-8') === '။'){
+            $needle = mb_substr($needle,0,-1);
+        }
+        for ($i=1; $i <= $maxLen ; $i++) {
+            $mEnding = mb_substr($needle,-$i);
+            if(in_array($mEnding,$endings)){
+                return [mb_substr($needle,0,mb_strlen($needle,'UTF-8')-$i),$mEnding];
+            }
+        }
+        return [$needle,''];
+    }
+}

+ 125 - 0
api-v13/app/Console/Commands/ExportOffline.php

@@ -0,0 +1,125 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Redis;
+
+class ExportOffline extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan export:offline lzma
+     * @var string
+     */
+    protected $signature = 'export:offline {format?  : zip file format 7z,lzma,gz } {--shortcut}  {--driver=morus}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'export  offline data for app';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+        $exportDir = storage_path('app/public/export/offline');
+        if (!is_dir($exportDir)) {
+            $res = mkdir($exportDir, 0755, true);
+            if (!$res) {
+                Log::error('mkdir fail path=' . $exportDir);
+                return 1;
+            }
+        }
+
+        //清空redis
+        Cache::put('/offline/index', []);
+
+        //删除全部的旧文件
+        foreach (scandir($exportDir) as $key => $file) {
+            if (is_file($exportDir . '/' . $file)) {
+                unlink($exportDir . '/' . $file);
+            }
+        }
+        //添加 .stop
+        $exportStop = $exportDir . '/.stop';
+        $file = fopen($exportStop, 'w');
+        fclose($file);
+
+        //建表
+        $this->info('create db');
+        $this->call('export:create.db');
+
+        //term
+        $this->info('export term start');
+        $this->call('export:term');
+
+        //导出channel
+        $this->info('export channel start');
+        $this->call('export:channel', ['db' => 'wikipali-offline']);
+        $this->call('export:channel', ['db' => 'wikipali-offline-index']);
+
+        if (!$this->option('shortcut')) {
+            //tag
+            $this->info('export tag start');
+            $this->call('export:tag', ['db' => 'wikipali-offline']);
+            $this->call('export:tag.map', ['db' => 'wikipali-offline']);
+            //
+            $this->info('export pali text start');
+            $this->call('export:pali.text');
+            //导出章节索引
+            $this->info('export chapter start');
+            $this->call('export:chapter.index', ['db' => 'wikipali-offline']);
+            $this->call('export:chapter.index', ['db' => 'wikipali-offline-index']);
+            //导出译文
+            $this->info('export sentence start');
+            $this->call('export:sentence', ['--type' => 'translation', '--driver' => $this->option('driver')]);
+            $this->call('export:sentence', ['--type' => 'nissaya', '--driver' => $this->option('driver')]);
+            //导出原文
+            $this->call('export:sentence', ['--type' => 'original', '--driver' => $this->option('driver')]);
+        }
+
+        $this->info('zip');
+        Log::info('export offline: db写入完毕 开始压缩');
+
+        sleep(5);
+        $this->call('export:zip', [
+            'id' => 'index',
+            'filename' => 'wikipali-offline-index' . '-' . date("Y-m-d") . '.db3',
+            'title' => 'wikipali 离线包索引',
+            'format' => $this->argument('format'),
+        ]);
+        $this->call('export:zip', [
+            'id' => 'date-package',
+            'filename' => 'wikipali-offline' . '-' . date("Y-m-d") . '.db3',
+            'title' => 'wikipali 离线包',
+            'format' => $this->argument('format'),
+        ]);
+
+        $this->call('export:ai.training.data');
+        $this->call('export:ai.pali.word.token');
+        unlink($exportStop);
+        return 0;
+    }
+}

+ 91 - 0
api-v13/app/Console/Commands/ExportPaliSynonyms.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\DictApi;
+use App\Models\UserDict;
+use App\Models\DhammaTerm;
+
+class ExportPaliSynonyms extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan export:pali.synonyms --output=
+     * @var string
+     */
+    protected $signature = 'export:pali.synonyms {--output=}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '导出openSearch用的巴利语变格表';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if (!$this->option('output')) {
+            $this->error('please set output file option --output=file');
+            return 1;
+        }
+        //irregular
+        $dictId = ['4d3a0d92-0adc-4052-80f5-512a2603d0e8'];
+        //regular
+        $dictId[] = DictApi::getSysDict('system_regular');
+
+        $filename = $this->option('output');
+        $fp = fopen($filename, 'w') or die("Unable to open file!");
+        foreach ($dictId as  $dict) {
+            $parents = UserDict::where('dict_id', $dict)
+                ->select('parent')
+                ->groupBy('parent')->cursor();
+
+            foreach ($parents as  $parent) {
+                $words = UserDict::where('dict_id', $dict)
+                    ->where('parent', $parent->parent)
+                    ->select('word')
+                    ->groupBy('word')->get();
+                $wordsList = [];
+                foreach ($words as $word) {
+                    $wordsList[$word->word] = 1;
+                }
+                $teams = DhammaTerm::where('word', $parent->parent)
+                    ->select(['meaning'])->get();
+                foreach ($teams as $term) {
+                    $wordsList[$term->meaning] = 1;
+                }
+                $this->info("[{$parent->parent}] " . count($words) . " team=" . count($teams));
+                // 合并 $parent->parent, $words->word, $team->meaning 为一个字符串数组
+                $combinedArray = [];
+                $combinedArray[] = $parent->parent;
+                foreach ($wordsList as $word => $value) {
+                    $combinedArray[] = $word;
+                }
+
+                // 将 $combinedArray 写入 CSV 文件
+                fputcsv($fp, $combinedArray);
+            }
+        }
+
+        // 关闭文件
+        fclose($fp);
+        $this->info('done');
+        return 0;
+    }
+}

+ 87 - 0
api-v13/app/Console/Commands/ExportPalitext.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use App\Models\PaliText;
+use Illuminate\Support\Facades\Log;
+
+class ExportPalitext extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:pali.text';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '导出离线用的巴利段落数据';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        Log::debug('task export offline palitext-table start');
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+        $exportFile = storage_path('app/public/export/offline/wikipali-offline-'.date("Y-m-d").'.db3');
+        $dbh = new \PDO('sqlite:'.$exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
+        $dbh->beginTransaction();
+
+        $query = "INSERT INTO pali_text ( id , book , paragraph, level, toc,
+                                    chapter_len , parent   )
+                                    VALUES ( ? , ? , ? , ? , ? , ? , ? )";
+        try{
+            $stmt = $dbh->prepare($query);
+        }catch(PDOException $e){
+            Log::info($e);
+            return 1;
+        }
+
+        $bar = $this->output->createProgressBar(PaliText::count());
+        foreach (PaliText::select(['uid','book','paragraph',
+                    'level','toc','lenght','chapter_len',
+                    'next_chapter','prev_chapter','parent','chapter_strlen'])
+                    ->orderBy('book')
+                    ->orderBy('paragraph')
+                    ->cursor() as $chapter) {
+            $currData = array(
+                            $chapter->uid,
+                            $chapter->book,
+                            $chapter->paragraph,
+                            $chapter->level,
+                            $chapter->toc,
+                            $chapter->chapter_len,
+                            $chapter->parent,
+                            );
+            $stmt->execute($currData);
+            $bar->advance();
+        }
+        $dbh->commit();
+        $bar->finish();
+        Log::debug('task: export offline palitext-table finished');
+
+        return 0;
+    }
+}

+ 131 - 0
api-v13/app/Console/Commands/ExportSentence.php

@@ -0,0 +1,131 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use App\Models\Sentence;
+use App\Models\Channel;
+use App\Http\Api\ChannelApi;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+use App\Http\Api\MdRender;
+
+class ExportSentence extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:sentence {--channel=} {--type=translation} {--driver=morus}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        Log::debug('task export offline sentence-table start');
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+        \App\Tools\Markdown::driver($this->option('driver'));
+        $channels = [];
+        $channel_id = $this->option('channel');
+        if($channel_id){
+            $file_suf = $channel_id;
+            $channels[] = $channel_id;
+        }else{
+            $channel_type = $this->option('type');
+            $file_suf = $channel_type;
+            if($channel_type === "original"){
+                $pali_channel = ChannelApi::getSysChannel("_System_Pali_VRI_");
+                if($pali_channel === false){
+                    return 0;
+                }
+                $channels[] = $pali_channel;
+            }else{
+                $nissaya_channel = Channel::where('type',$channel_type)->where('status',30)->select('uid')->get();
+                foreach ($nissaya_channel as $key => $value) {
+                    # code...
+                    $channels[] = $value->uid;
+                }
+            }
+        }
+
+
+        $exportFile = storage_path('app/public/export/offline/wikipali-offline-'.date("Y-m-d").'.db3');
+        $dbh = new \PDO('sqlite:'.$exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
+        $dbh->beginTransaction();
+
+        if($channel_type === "original"){
+            $table = 'sentence';
+        }else{
+            $table = 'sentence_translation';
+        }
+
+        $query = "INSERT INTO {$table} ( book , paragraph ,
+                                    word_start , word_end , content , channel_id  )
+                                    VALUES ( ? , ? , ? , ? , ? , ? )";
+        try{
+            $stmt = $dbh->prepare($query);
+        }catch(PDOException $e){
+            Log::info($e);
+            return 1;
+        }
+
+        $db = Sentence::whereIn('channel_uid',$channels);
+        $bar = $this->output->createProgressBar($db->count());
+        $srcDb = $db->select(['uid','book_id','paragraph',
+                                'word_start','word_end',
+                                'content','content_type','channel_uid',
+                                'editor_uid','language','updated_at'])->cursor();
+        foreach ($srcDb as $sent) {
+            if(Str::isUuid($sent->channel_uid)){
+                $channel = ChannelApi::getById($sent->channel_uid);
+                $currData = array(
+                        $sent->book_id,
+                        $sent->paragraph,
+                        $sent->word_start,
+                        $sent->word_end,
+                        MdRender::render($sent->content,
+                                        [$sent->channel_uid],
+                                        null,
+                                        'read',
+                                        $channel['type'],
+                                        $sent->content_type,
+                                        'unity',
+                                        ),
+                        $sent->channel_uid,
+                    );
+                $stmt->execute($currData);
+
+            }
+            $bar->advance();
+        }
+        $dbh->commit();
+        $bar->finish();
+        Log::debug('task export sentence finished');
+        return 0;
+    }
+}

+ 80 - 0
api-v13/app/Console/Commands/ExportTag.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use App\Models\Tag;
+use Illuminate\Support\Facades\Log;
+
+class ExportTag extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:tag {db}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        Log::debug('task: export offline data tag-table start');
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+        $exportFile = storage_path('app/public/export/offline/'.$this->argument('db').'-'.date("Y-m-d").'.db3');
+        $dbh = new \PDO('sqlite:'.$exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
+        $dbh->beginTransaction();
+
+        $query = "INSERT INTO tag ( id , name ,
+                                    description , color , owner_id  )
+                                    VALUES ( ? , ? , ? , ? , ?  )";
+        try{
+            $stmt = $dbh->prepare($query);
+        }catch(PDOException $e){
+            Log::info($e);
+            return 1;
+        }
+
+        $bar = $this->output->createProgressBar(Tag::count());
+        foreach (Tag::select(['id','name','description','color','owner_id'])->cursor() as $row) {
+            $currData = array(
+                $row->id,
+                $row->name,
+                $row->description,
+                $row->color,
+                $row->owner_id,
+            );
+            $stmt->execute($currData);
+            $bar->advance();
+        }
+        $dbh->commit();
+        $bar->finish();
+        Log::debug('task: export offline data tag-table start');
+
+        return 0;
+    }
+}

+ 75 - 0
api-v13/app/Console/Commands/ExportTagmap.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use App\Models\TagMap;
+use Illuminate\Support\Facades\Log;
+
+class ExportTagmap extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:tag.map {db}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        Log::debug('task: export offline tagmap-table start');
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+        $exportFile = storage_path('app/public/export/offline/'.$this->argument('db').'-'.date("Y-m-d").'.db3');
+        $dbh = new \PDO('sqlite:'.$exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
+        $dbh->beginTransaction();
+
+        $query = "INSERT INTO tag_map ( anchor_id , tag_id )
+                                    VALUES ( ? , ? )";
+        try{
+            $stmt = $dbh->prepare($query);
+        }catch(PDOException $e){
+            Log::info($e);
+            return 1;
+        }
+
+        $bar = $this->output->createProgressBar(TagMap::count());
+        foreach (TagMap::select(['id','table_name','anchor_id','tag_id'])->cursor() as $row) {
+            $currData = array(
+                            $row->anchor_id,
+                            $row->tag_id,
+                            );
+            $stmt->execute($currData);
+            $bar->advance();
+        }
+        $dbh->commit();
+        $bar->finish();
+        Log::debug('task: export offline tagmap-table finished');
+        return 0;
+    }
+}

+ 100 - 0
api-v13/app/Console/Commands/ExportTerm.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use App\Models\DhammaTerm;
+use Illuminate\Support\Facades\Log;
+
+class ExportTerm extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:term';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        Log::info('task export offline term-table start');
+        $startAt = time();
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+        $exportFile = storage_path('app/public/export/offline/wikipali-offline-'.date("Y-m-d").'.db3');
+        $dbh = new \PDO('sqlite:'.$exportFile, "", "", array(\PDO::ATTR_PERSISTENT => true));
+        $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_WARNING);
+        $dbh->beginTransaction();
+
+        $query = "INSERT INTO dhamma_terms ( uuid , word , word_en , meaning ,
+                                    other_meaning , note , tag , channel_id,
+                                    language, owner, editor_id,
+                                    created_at,updated_at,deleted_at)
+                                    VALUES ( ? , ? , ? , ? ,
+                                            ? , ? , ? , ? ,
+                                            ?, ?, ?,
+                                            ?, ?, ? )";
+        try{
+            $stmt = $dbh->prepare($query);
+        }catch(PDOException $e){
+            Log::info($e);
+            return 1;
+        }
+
+        $bar = $this->output->createProgressBar(DhammaTerm::count());
+        foreach (DhammaTerm::select(['guid','word','word_en','meaning',
+                          'other_meaning','note','tag','channal',
+                          'language',"owner","editor_id",
+                          "created_at","updated_at","deleted_at"
+                          ])
+                          ->cursor() as $row) {
+                $currData = array(
+                            $row->guid,
+                            $row->word,
+                            $row->word_en,
+                            $row->meaning,
+                            $row->other_meaning,
+                            $row->note,
+                            $row->tag,
+                            $row->channal,
+                            $row->language,
+                            $row->owner,
+                            $row->editor_id,
+                            $row->created_at,
+                            $row->updated_at,
+                            $row->deleted_at,
+                            );
+            $stmt->execute($currData);
+            $bar->advance();
+        }
+        $dbh->commit();
+        $bar->finish();
+        $this->info(' time='.(time()-$startAt).'s');
+        Log::info('task export offline term-table finished');
+        return 0;
+    }
+}

+ 203 - 0
api-v13/app/Console/Commands/ExportZip.php

@@ -0,0 +1,203 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\App;
+
+use Symfony\Component\Process\Process;
+use Symfony\Component\Process\Exception\ProcessFailedException;
+
+class ExportZip extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:zip {filename : filename} {title : title} {id : 标识符} {format?  : zip file format 7z,lzma,gz }';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '压缩导出的文件';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        Log::debug('export offline: 开始压缩');
+        $defaultExportPath = storage_path('app/public/export/offline');
+        $exportFile = $this->argument('filename');
+        $filename = basename($exportFile);
+        if ($filename === $exportFile) {
+            $exportFullFileName = $defaultExportPath . '/' . $filename;
+            $exportPath = $defaultExportPath;
+        } else {
+            $exportFullFileName = $exportFile;
+            $exportPath = dirname($exportFile);
+        }
+        Log::debug(
+            'export offline: zip file {filename} {format}',
+            [
+                'filename' => $exportFile,
+                'format' => $this->argument('format'),
+                'exportFullFileName' => $exportFullFileName,
+                'exportPath' => $exportPath,
+            ]
+        );
+        switch ($this->argument('format')) {
+            case '7z':
+                $zipFile = $filename . ".7z";
+                break;
+            case 'lzma':
+                $zipFile = $filename . ".lzma";
+                break;
+            default:
+                $zipFile = $filename . ".gz";
+                break;
+        }
+        //
+        if (!file_exists($exportFullFileName)) {
+            Log::error('export offline: no  file {filename}', ['filename' => $exportFullFileName]);
+            $this->error('export offline: no  file {filename}' . $exportFullFileName);
+            return 1;
+        }
+
+        $zipFullFileName = $exportPath . '/' . $zipFile;
+        if (file_exists($zipFullFileName)) {
+            Log::debug('export offline: delete old zip file:' . $zipFullFileName);
+            unlink($zipFullFileName);
+        }
+
+        shell_exec("cd " . $exportPath);
+        switch ($this->argument('format')) {
+            case '7z':
+                $command = [
+                    '7z',
+                    'a',
+                    '-t7z',
+                    '-m0=lzma',
+                    '-mx=9',
+                    '-mfb=64',
+                    '-md=32m',
+                    '-ms=on',
+                    $zipFullFileName,
+                    $exportFullFileName
+                ];
+                break;
+            case 'lzma':
+                $command = ['xz', '-k', '-9', '--format=lzma', $exportFullFileName];
+                break;
+            default:
+                $command = ['gzip', $exportFullFileName];
+                break;
+        }
+
+        $this->info(implode(' ', $command));
+        Log::debug('export offline zip start', ['command' => $command, 'format' => $this->argument('format')]);
+        $process = new Process($command);
+        $process->setTimeout(60 * 60 * 6);
+        $process->run();
+        $this->info($process->getOutput());
+        $this->info('压缩完成');
+        Log::debug(
+            'zip file {filename} in {format} saved.',
+            [
+                'filename' => $exportFile,
+                'format' => $this->argument('format')
+            ]
+        );
+
+        $url = array();
+        foreach (config('mint.server.cdn_urls') as $key => $cdn) {
+            $url[] = [
+                'link' => $cdn . '/' . $zipFile,
+                'hostname' => 'china cdn-' . $key,
+            ];
+        }
+
+        $bucket = config('mint.attachments.bucket_name.temporary');
+        $tmpFile =  $bucket . '/' . $zipFile;
+
+        $this->info('upload file=' . $tmpFile);
+        Log::debug('export offline: upload file {filename}', ['filename' => $tmpFile]);
+
+        Storage::put($tmpFile, file_get_contents($zipFullFileName));
+
+        $this->info('upload done file=' . $tmpFile);
+        Log::debug('export offline: upload done {filename}', ['filename' => $tmpFile]);
+
+        if (App::environment('local')) {
+            $link = Storage::url($tmpFile);
+        } else {
+            try {
+                $link = Storage::temporaryUrl($tmpFile, now()->addDays(2));
+            } catch (\Exception $e) {
+                $this->error('generate temporaryUrl fail');
+                Log::error(
+                    'export offline: generate temporaryUrl fail {Exception}',
+                    [
+                        'exception' => $e,
+                        'file' => $tmpFile
+                    ]
+                );
+                return 1;
+            }
+        }
+        $this->info('link = ' . $link);
+        Log::info('export offline: link=' . $link);
+
+        $url[] = [
+            'link' => $link,
+            'hostname' => 'Amazon cloud storage(Hongkong)',
+        ];
+        $info = Cache::get('/offline/index');
+        if (!is_array($info)) {
+            $info = array();
+        }
+        $info[] = [
+            'id' => $this->argument('id'),
+            'title' => $this->argument('title'),
+            'filename' => $zipFile,
+            'url' => $url,
+            'create_at' => date("Y-m-d H:i:s"),
+            'chapter' => Cache::get("/export/chapter/count"),
+            'filesize' => filesize($zipFullFileName),
+            'min_app_ver' => '1.3',
+        ];
+        Cache::put('/offline/index', $info);
+        sleep(5);
+        try {
+            unlink($exportFullFileName);
+        } catch (\Throwable $th) {
+            Log::error(
+                'export offline: delete  file fail {Exception}',
+                [
+                    'exception' => $th,
+                    'file' => $exportFullFileName
+                ]
+            );
+        }
+
+        return 0;
+    }
+}

+ 277 - 0
api-v13/app/Console/Commands/ExportZip2.php

@@ -0,0 +1,277 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\App;
+
+use Symfony\Component\Process\Process;
+
+class ExportZip2 extends Command
+{
+    protected $signature = 'export:zip2
+        {filename : filename}
+        {title : title}
+        {id : 标识符}
+        {format? : zip file format 7z,lzma,gz }';
+
+    protected $description = '压缩导出的文件';
+
+    public function handle()
+    {
+        Log::debug('export offline: 开始压缩');
+
+        $defaultExportPath = storage_path('app/public/export/offline');
+        $exportFile = $this->argument('filename');
+
+        $filename = basename($exportFile);
+
+        if ($filename === $exportFile) {
+            $exportFullFileName = $defaultExportPath . '/' . $filename;
+            $exportPath = $defaultExportPath;
+        } else {
+            $exportFullFileName = $exportFile;
+            $exportPath = dirname($exportFile);
+        }
+
+        $format = $this->argument('format') ?? 'gz';
+
+        if (!file_exists($exportFullFileName)) {
+
+            Log::error('export offline: file not exists', [
+                'file' => $exportFullFileName
+            ]);
+
+            $this->error('file not exists: ' . $exportFullFileName);
+
+            return 1;
+        }
+
+        $zipFile = $this->getZipFileName($filename, $format);
+        $zipFullFileName = $exportPath . '/' . $zipFile;
+
+        if (file_exists($zipFullFileName)) {
+            unlink($zipFullFileName);
+        }
+
+        $this->info("start compress: {$exportFullFileName}");
+        Log::debug('export offline zip start', [
+            'file' => $exportFullFileName,
+            'format' => $format
+        ]);
+
+        $this->compress($exportFullFileName, $zipFullFileName, $format);
+
+        $this->info('压缩完成');
+
+        Log::debug('zip done', [
+            'zip' => $zipFullFileName
+        ]);
+
+        /*
+        |--------------------------------------------------------------------------
+        | 上传 S3
+        |--------------------------------------------------------------------------
+        */
+
+        $bucket = config('mint.attachments.bucket_name.temporary');
+        $tmpFile = $bucket . '/' . $zipFile;
+
+        $this->info('upload file=' . $tmpFile);
+
+        Log::debug('export offline upload', [
+            'file' => $tmpFile
+        ]);
+
+        Storage::put($tmpFile, fopen($zipFullFileName, 'r'));
+
+        $this->info('upload done');
+
+        Log::debug('upload done');
+
+        /*
+        |--------------------------------------------------------------------------
+        | 生成下载链接
+        |--------------------------------------------------------------------------
+        */
+
+        if (App::environment('local')) {
+            $link = Storage::url($tmpFile);
+        } else {
+            try {
+                $link = Storage::temporaryUrl(
+                    $tmpFile,
+                    now()->addDays(2)
+                );
+            } catch (\Exception $e) {
+                Log::error('temporaryUrl fail', [
+                    'exception' => $e
+                ]);
+                $this->error('generate temporaryUrl fail');
+                return 1;
+            }
+        }
+
+        $this->info('link=' . $link);
+
+        /*
+        |--------------------------------------------------------------------------
+        | CDN 列表
+        |--------------------------------------------------------------------------
+        */
+
+        $url = [];
+        foreach (config('mint.server.cdn_urls') as $key => $cdn) {
+            $url[] = [
+                'link' => $cdn . '/' . $zipFile,
+                'hostname' => 'china cdn-' . $key
+            ];
+        }
+
+        $url[] = [
+            'link' => $link,
+            'hostname' => 'Amazon cloud storage(Hongkong)'
+        ];
+
+        /*
+        |--------------------------------------------------------------------------
+        | Cache 写入
+        |--------------------------------------------------------------------------
+        */
+
+        $info = Cache::get('/offline/index', []);
+        if (!is_array($info)) {
+            $info = [];
+        }
+        $id = $this->argument('id');
+        // 先移除已有相同 id 的记录
+        $info = array_values(array_filter($info, function ($item) use ($id) {
+            return !isset($item['id']) || $item['id'] != $id;
+        }));
+        // 再追加新数据
+        $info[] = [
+            'id' => $id,
+            'title' => $this->argument('title'),
+            'filename' => $zipFile,
+            'url' => $url,
+            'create_at' => now()->toDateTimeString(),
+            'chapter' => Cache::get("/export/chapter/count"),
+            'filesize' => filesize($zipFullFileName),
+            'min_app_ver' => '1.3',
+        ];
+
+        Cache::put('/offline/index', $info);
+
+        /*
+        |--------------------------------------------------------------------------
+        | 删除原始文件
+        |--------------------------------------------------------------------------
+        */
+
+        sleep(5);
+        try {
+            if (is_file($exportFullFileName)) {
+                unlink($exportFullFileName);
+            }
+            if (file_exists($zipFullFileName)) {
+                unlink($zipFullFileName);
+            }
+        } catch (\Throwable $e) {
+            Log::error('delete source fail', [
+                'exception' => $e
+            ]);
+        }
+
+        return 0;
+    }
+
+    /*
+    |--------------------------------------------------------------------------
+    | 生成压缩文件名
+    |--------------------------------------------------------------------------
+    */
+
+    protected function getZipFileName(string $filename, string $format): string
+    {
+        return match ($format) {
+            '7z' => $filename . '.7z',
+            'lzma' => $filename . '.lzma',
+            default => $filename . '.tar.gz'
+        };
+    }
+
+    /*
+    |--------------------------------------------------------------------------
+    | 压缩函数
+    |--------------------------------------------------------------------------
+    */
+
+    protected function compress($source, $target, $format)
+    {
+        $isDir = is_dir($source);
+        switch ($format) {
+            case '7z':
+                $command = [
+                    '7z',
+                    'a',
+                    '-t7z',
+                    '-mx=9',
+                    $target,
+                    $source
+                ];
+                break;
+
+            case 'lzma':
+                if ($isDir) {
+                    $tmpTar = $source . '.tar';
+                    $tar = new Process([
+                        'tar',
+                        '-cf',
+                        $tmpTar,
+                        '-C',
+                        dirname($source),
+                        basename($source)
+                    ]);
+                    $tar->run();
+                    $source = $tmpTar;
+                }
+                $command = [
+                    'xz',
+                    '-k',
+                    '-9',
+                    '--format=lzma',
+                    $source
+                ];
+                break;
+
+            default:
+                $command = [
+                    'tar',
+                    '-czf',
+                    $target,
+                    '-C',
+                    dirname($source),
+                    basename($source)
+                ];
+        }
+
+        $this->info(implode(' ', $command));
+        $process = new Process($command);
+        $process->setTimeout(60 * 60 * 6);
+        $process->run();
+
+        $this->info($process->getOutput());
+
+        if (!$process->isSuccessful()) {
+
+            Log::error('compress fail', [
+                'error' => $process->getErrorOutput()
+            ]);
+
+            throw new \RuntimeException($process->getErrorOutput());
+        }
+    }
+}

+ 147 - 0
api-v13/app/Console/Commands/ImportArticle.php

@@ -0,0 +1,147 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use App\Http\Api\StudioApi;
+use App\Models\Article;
+
+class ImportArticle extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan import:article --studio=visuddhinanda --anthology=eb9e3f7f-b942-4ca4-bd6f-b7876b59a523 --token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJuYmYiOjE2OTc3Mjg2ODUsImV4cCI6MTcyOTI2NDY4NSwidWlkIjoiYmE1NDYzZjMtNzJkMS00NDEwLTg1OGUtZWFkZDEwODg0NzEzIiwiaWQiOjR9.fiXhnY2LczZ9kKVHV0FfD3AJPZt-uqM5wrDe4EhToVexdd007ebPFYssZefmchfL0mx9nF0rgHSqjNhx4P0yDA
+     * @var string
+     */
+    protected $signature = 'import:article {--studio=} {--anthology=} {--token=}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '导入缅文tipitaka sarupa文章';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     * 分两个步骤导入
+     * 1. 导入文章到文集
+     * 2. 重新生成目录结构
+     * @return int
+     */
+    public function handle()
+    {
+        if (!$this->confirm('Do you wish to continue?')) {
+            return 0;
+        }
+        $token = $this->option('token');
+        $studioName = $this->option('studio');
+        $anthologyId = $this->option('anthology');
+
+        //先获取文章列表,建立全部目录
+        $head = array();
+        $strFileName = __DIR__."/tipitaka-sarupa.csv";
+        if(!file_exists($strFileName)){
+            $this->error($strFileName.'文件不存在');
+            return 1;
+        }
+
+        if (($fp = fopen($strFileName, "r")) === false) {
+            $this->error("can not open csv {$strFileName}");
+            return 0;
+        }
+        $this->info('打开csv文件成功');
+
+        $studioId = StudioApi::getIdByName($studioName);
+        if(!$studioId){
+            $this->error("can not found studio name {$studioName}");
+            return 0;
+        }
+        //导入文章
+        $url = config('app.url').'/api/v2/article';
+        $inputRow = 0;
+        fseek($fp, 0);
+        $count = 0;
+        $success = 0;
+        $fail = 0;
+        while (($data = fgetcsv($fp, 0, ',')) !== false) {
+            if($inputRow>0){
+                $id = $data[0];
+                $dir = $data[1];
+                $title = $data[2];
+                $realTitle = "[{$id}]{$title}";
+                $content = str_replace('\n',"\n",$data[4]) ;
+                $reference = str_replace(['(',')'],['({{ql|type=m|title=','}})'],$data[5]);
+                $contentCombine = "{$title}\n\n{$content}\n\n{$reference}";
+                $percent = (int)($inputRow*100/7000);
+                $this->info("[{$percent}%] doing ".$realTitle);
+                //先查是否有
+                $hasArticle = Article::where('owner',$studioId)
+                              ->where('title',$realTitle)
+                              ->exists();
+                if($hasArticle){
+                    $this->error('文章已经存在 title='.$realTitle);
+                    continue;
+                }
+                $count++;
+                $this->info('新建 title='.$realTitle);
+                sleep(2);
+                $response = Http::withToken($token)->post($url,
+                                [
+                                    'title'=> $realTitle,
+                                    'lang'=> 'my',
+                                    'studio'=> $studioName,
+                                    'anthologyId'=> $anthologyId,
+                                ]);
+                if($response->ok()){
+                    $this->info('create ok');
+                    $articleId = $response->json('data')['uid'];
+                }else{
+                    $this->error('create article fail.'.$realTitle);
+                    Log::error('create article fail title='.$realTitle);
+                    $fail++;
+                    continue;
+                }
+                sleep(2);
+                $this->info('修改 id='.$articleId);
+                $response = Http::withToken($token)->put($url.'/'.$articleId,
+                                    [
+                                        'title'=> $realTitle,
+                                        'summary'=> $title.'#'.$id,
+                                        'lang'=> 'my',
+                                        'content'=> $contentCombine,
+                                        'anthology_id'=>$anthologyId,
+                                        'to_tpl'=>true,
+                                        'status'=>30,
+                                    ]);
+
+                if($response->ok()){
+                    $this->info('edit ok');
+                    $success++;
+                }else{
+                    $this->error('edit article fail');
+                    Log::error('edit article fail ',['id'=>$articleId,'title'=>$realTitle]);
+                    $fail++;
+                }
+            }
+            $inputRow++;
+        }
+
+        fclose($fp);
+
+        $this->info('成功='.$success.' 失败='.$fail);
+        return 0;
+    }
+}

+ 213 - 0
api-v13/app/Console/Commands/ImportArticleMap.php

@@ -0,0 +1,213 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use App\Http\Api\StudioApi;
+use App\Models\Article;
+use App\Models\Collection;
+
+class ImportArticleMap extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan import:article.map visuddhinanda --studio=visuddhinanda --size=30000 --anthology=4c6b661b-fd68-44c5-8918-2e327c870b9a --token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJuYmYiOjE2OTc3Mjg2ODUsImV4cCI6MTcyOTI2NDY4NSwidWlkIjoiYmE1NDYzZjMtNzJkMS00NDEwLTg1OGUtZWFkZDEwODg0NzEzIiwiaWQiOjR9.fiXhnY2LczZ9kKVHV0FfD3AJPZt-uqM5wrDe4EhToVexdd007ebPFYssZefmchfL0mx9nF0rgHSqjNhx4P0yDA
+     *
+     * @var string
+     */
+    protected $signature = 'import:article.map {src_studio} {--token=} {--studio=} {--anthology=} {--size=}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '重置缅文tipitaka sarupa文章目录';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $token = $this->option('token');
+        $studioName = $this->option('studio');
+        $anthologyId = $this->option('anthology');
+        $srcStudio = $this->argument('src_studio');
+        if (!$this->confirm('Do you wish to continue?')) {
+            return 0;
+        }
+        $studioId = StudioApi::getIdByName($studioName);
+        if(!$studioId){
+            $this->error("can not found studio name {$studioName}");
+            return 0;
+        }
+        $srcStudioId = StudioApi::getIdByName($srcStudio);
+        if(!$srcStudioId){
+            $this->error("can not found src studio name {$srcStudio}");
+            return 0;
+        }
+
+        //先获取文章列表,建立全部目录
+        $url = config('app.url').'/api/v2/article-map';
+
+        $this->info('打开csv文件并读取数据');
+        $head = array();
+        $strFileName = __DIR__."/tipitaka-sarupa.csv";
+        if(!file_exists($strFileName)){
+            $this->error($strFileName.'文件不存在');
+            return 1;
+        }
+
+        if (($fp = fopen($strFileName, "r")) === false) {
+            $this->error("can not open csv {$strFileName}");
+            return 0;
+        }
+        //查询文集语言
+        $srcAnthology = Collection::where('uid',$anthologyId)->first();
+        if(!$srcAnthology){
+            $this->error("文集不存在 anthologyId=".$anthologyId);
+            return 0;
+        }
+        $lang = $srcAnthology->lang;
+        if(empty($lang)){
+            $this->error("文集语言不能为空 anthologyId=".$anthologyId);
+            return 0;
+        }
+        $inputRow = 0;
+        $currSize=0;
+        $currBlock=1;
+        $currDir='';
+        $success = 0;
+        $fail = 0;
+        $articleMap = array();
+        while (($data = fgetcsv($fp, 0, ',')) !== false) {
+            if($inputRow>0){
+                $id = $data[0];
+                $dir = $data[1];
+                $title = $data[2];
+                $realTitle = "[{$id}]{$title}";
+                $realTitle = mb_substr($realTitle,0,128,'UTF-8');
+                $reference = $data[5];
+
+                $percent = (int)($inputRow*100/6984);
+                $this->info("[{$percent}%] doing ".$realTitle);
+
+                if($this->option('size')){
+                    $currDir = $srcAnthology->title . '-' . $currBlock;
+                    if($currSize > $this->option('size')){
+                        $currBlock++;
+                        $currSize=0;
+                    }
+                }else{
+                    $currDir = $dir;
+                }
+                //查找目录文章是否存在
+                $dirArticle = Article::where('owner',$studioId)
+                              ->where('title',$currDir)
+                              ->first();
+                if($dirArticle){
+                    $dirId = $dirArticle->uid;
+                }else{
+                    $this->info('不存在目录'.$currDir.'新建');
+                    $url = config('app.url').'/api/v2/article';
+                    sleep(2);
+                    $response = Http::withToken($token)->post($url,
+                    [
+                        'title'=> $currDir,
+                        'lang'=> $lang,
+                        'studio'=> $studioName,
+                        'anthologyId'=> $anthologyId,
+                    ]);
+                    if($response->ok()){
+                        $this->info('dir create ok title='.$currDir);
+                        $dirId = $response->json('data.uid');
+                    }else{
+                        $this->error('create dir fail.'.$currDir);
+                        Log::error('create dir fail title='.$currDir);
+                        $fail++;
+                        continue;
+                    }
+                }
+                //创建目录结束
+                if(!isset($articleMap[$dirId])){
+                    $articleMap[$dirId] = ['name'=>$currDir,'children'=>[]];
+                }
+                //查找文章
+                $article = Article::where('owner',$srcStudioId)
+                              ->where('title',$realTitle)
+                              ->first();
+                if(!$article){
+                    $this->error('文章没找到.'.$realTitle);
+                    Log::error('文章没找到 title='.$realTitle);
+                    $fail++;
+                    continue;
+                }
+                $articleMap[$dirId]['children'][] = [
+                    'id'=>$article->uid,
+                    'title'=>$article->title,
+                ];
+                if($this->option('size')){
+                    $currSize += mb_strlen($title,'UTF-8') +
+                                mb_strlen($data[4],'UTF-8') +
+                                mb_strlen($reference,'UTF-8');
+                }
+                $success++;
+            }
+            $inputRow++;
+        }
+        $this->info("找到文章=" .$success);
+        $this->info("目录=" .count($articleMap));
+
+        $this->info('正在准备map数据');
+
+        $data = array();
+        foreach ($articleMap as $dirId => $dir) {
+            $data[] = [
+                    'article_id'=> $dirId,
+                    'level'=> 1,
+                    'title'=> $dir['name'],
+                    'children'=> count($dir['children']),
+                    'deleted_at'=> null,
+            ];
+            foreach ($dir['children'] as $key => $child) {
+                $data[] = [
+                        'article_id'=> $child['id'],
+                        'level'=> 2,
+                        'title'=> $child['title'],
+                        'children'=> 0,
+                        'deleted_at'=> null,
+                ];
+            }
+        }
+        $this->info('map data='.count($data));
+
+        //目录写入db
+        $url = config('app.url').'/api/v2/article-map/'.$anthologyId;
+        $response = Http::withToken($token)->put($url,
+        [
+            'data'=> $data,
+            'operation' => "anthology",
+        ]);
+        if($response->ok()){
+            $this->info('map update ok ');
+        }else{
+            $this->error('map update  fail.');
+            Log::error('map update  fail ');
+        }
+        return 0;
+    }
+}

+ 41 - 0
api-v13/app/Console/Commands/IndexOpenSearch.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Services\OpenSearchService;
+use Illuminate\Support\Facades\App;
+
+
+class IndexOpenSearch extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'app:index-open-search';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '如果是新的索引 全量更新openSearch';
+
+    /**
+     * Execute the console command.
+     */
+    public function handle()
+    {
+        //
+        $service = app(OpenSearchService::class);
+        if ($service->count() === 0) {
+            $this->call('opensearch:index-pali');
+        } else {
+            if (App::environment('local')) {
+                $this->info('data exist');
+            }
+        }
+    }
+}

+ 202 - 0
api-v13/app/Console/Commands/IndexTerm.php

@@ -0,0 +1,202 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\DhammaTerm;
+use Illuminate\Console\Command;
+use App\Services\OpenSearchService;
+use App\Services\TermService;
+use Illuminate\Support\Facades\Log;
+
+class IndexTerm extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     *
+     * @example
+     *   php artisan opensearch:index-term
+     *   php artisan opensearch:index-term --word=anomadassī
+     *   php artisan opensearch:index-term --test
+     */
+    protected $signature = 'opensearch:index-term
+        {--test}
+        {--word= : 指定单个词条进行索引,省略则索引全部}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Index Term data into OpenSearch';
+
+    /** @var bool 是否为测试模式(只打印,不写入 OpenSearch) */
+    private bool $isTest = false;
+
+    /**
+     * Create a new command instance.
+     */
+    public function __construct(
+        protected OpenSearchService $openSearchService,
+        protected TermService $termService,
+    ) {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * 遍历所有(或指定)DhammaTerm,逐条构建文档并写入 OpenSearch。
+     * 测试模式下(--test)只打印文档内容,不执行写入。
+     *
+     * @return int  0 表示成功,1 表示失败
+     */
+    public function handle(): int
+    {
+        $word = $this->option('word');
+
+        if ($this->option('test')) {
+            $this->isTest = true;
+            $this->info('test mode');
+        }
+
+        try {
+            [$connected, $message] = $this->openSearchService->testConnection();
+            if (!$connected) {
+                $this->error($message);
+                Log::error($message);
+                return 1;
+            }
+
+            $total = DhammaTerm::count();
+            $terms = DhammaTerm::select(['guid', 'word'])->orderBy('updated_at', 'asc');
+
+            if ($word) {
+                $terms = $terms->where('word', $word);
+            }
+
+            $overallStatus = 0;
+
+            foreach ($terms->cursor() as $key => $term) {
+                $percent = (int) (($key * 100) / $total);
+                $this->info("[{$percent}%]-{$key}  " . $term->word);
+                $this->indexTerm($term->guid);
+            }
+
+            return $overallStatus;
+        } catch (\Exception $e) {
+            $this->error('Failed to index Term data: ' . $e->getMessage());
+            Log::error('Failed to index Term data', ['error' => $e]);
+            return 1;
+        }
+    }
+
+    /**
+     * 构建单条词条文档并写入 OpenSearch
+     *
+     * 文档结构遵循新版 mapping:
+     *   title.text.pali / title.text.zh  → 全文检索
+     *   title.suggest.pali / title.suggest.zh → 自动建议
+     *   content.text.pali / content.text.zh   → 正文内容
+     *
+     * @param  string  $id  DhammaTerm 的 guid
+     * @return void
+     */
+    protected function indexTerm(string $id): void
+    {
+        $termData    = $this->termService->find($id, 'text');
+        $channelName = $termData['channel']['name'] ?? '';
+        $isCommunity = $this->termService->isCommunity($termData['channel_id']);
+        $content     = $termData['html'] ?? $termData['meaning'];
+
+        $categories = $this->extractCategories($termData['note'] ?? '');
+        $quality = $this->extractFirstQuality($termData['note'] ?? '');
+        $tags = [];
+        foreach ($categories as $key => $category) {
+            $tags[] = "category:{$category}";
+        }
+        if (!empty($quality)) {
+            $tags[] = "quality:{$quality}";
+        }
+        $document = [
+            'id'            => "term_{$id}",
+            'resource_id'   => $id,
+            'resource_type' => 'term',
+            'title'         => [
+                'text' => [
+                    'pali' => $termData['word'],
+                    'zh'   => $termData['meaning'],
+                ],
+                'suggest' => [
+                    'pali' => [$termData['word']],
+                    'zh'   => [$termData['meaning']],
+                ],
+            ],
+            'summary' => [
+                'text' => $termData['summary'] ?? '',
+            ],
+            'content'     => [],
+            'bold_single' => [$termData['meaning'], $termData['word']],
+            'related_id'  => $termData['word'],
+            'category'    => null,
+            'tags'        => $tags,
+            'language'    => $termData['language'],
+            'updated_at'  => now()->toIso8601String(),
+            'path'        => $termData['studio']['realName'] . "/{$channelName}",
+            'metadata' => ['channel' => $termData['channel_id']],
+        ];
+
+        // TODO: 补充语言判断,将内容放入对应的 text.pali 或 text.zh 字段
+        $plainText = strip_tags($content);
+        if (str_contains($termData['language'], 'zh')) {
+            $document['content']['text']['zh'] = $plainText;
+        } else {
+            $document['content']['text']['zh'] = $plainText;
+        }
+        $document['content']['display']    = $content;             // 展示
+
+        if ($this->isTest) {
+            $this->info($document['title']['text']['pali']);
+            $this->info($document['summary']['text']);
+        } else {
+            $this->openSearchService->create($document['id'], $document);
+        }
+    }
+
+    /**
+     * 提取 Markdown 中的 {{category|...}} 分类标签
+     *
+     * @param string $content
+     * @return array
+     */
+    private function extractCategories(string $content): array
+    {
+        if (empty($content)) {
+            return [];
+        }
+        preg_match_all('/\{\{category\|([^}]+)\}\}/u', $content, $matches);
+
+        return array_values(array_filter(array_map(
+            fn($item) => trim($item),
+            $matches[1] ?? []
+        )));
+    }
+
+    /**
+     * 提取 Markdown 中第一个 {{quality|...}} 标签内的内容
+     *
+     * @param string $content
+     * @return string
+     */
+    private function extractFirstQuality(string $content): string
+    {
+        if (empty($content)) {
+            return '';
+        }
+
+        preg_match('/\{\{quality\|([^}]+)\}\}/u', $content, $matches);
+
+        return isset($matches[1]) ? trim($matches[1]) : '';
+    }
+}

+ 444 - 0
api-v13/app/Console/Commands/IndexTipitaka.php

@@ -0,0 +1,444 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Services\SearchPaliDataService;
+use App\Services\OpenSearchService;
+use App\Services\SummaryService;
+use App\Services\TagService;
+use Illuminate\Support\Facades\Log;
+use App\Models\PaliText;
+use App\Models\Sentence;
+use App\Services\PaliContentService;
+use App\Http\Api\ChannelApi;
+use App\Models\ProgressChapter;
+
+class IndexTipitaka extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan opensearch:index-tipitaka 93 --para=6 --granularity=chapter
+     * @var string
+     */
+    protected $signature = 'opensearch:index-tipitaka {book : The book ID to index data for}
+    {--test}
+    {--para= : index paragraph No. omit to all}
+    {--summary=on}
+    {--resume}
+    {--granularity=all : The granularity to index (paragraph, sutta, sentence; omit to index all)}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Index Pali data into OpenSearch for a specified book and optional granularity (all granularities if not specified)';
+
+
+    private $isTest = false;
+    private $summary = false;
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected SearchPaliDataService $searchPaliDataService,
+        protected OpenSearchService $openSearchService,
+        protected SummaryService $summaryService,
+        protected TagService $tagService
+    ) {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $book = (int)$this->argument('book');
+        $granularity = $this->option('granularity');
+        $paragraph = $this->option('para');
+        $this->summary = $this->option('summary') === 'on';
+
+        if ($this->option('test')) {
+            $this->isTest = true;
+            $this->info('test mode');
+        }
+
+
+        try {
+            // Test OpenSearch connection
+            [$connected, $message] = $this->openSearchService->testConnection();
+            if (!$connected) {
+                $this->error($message);
+                Log::error($message);
+                return 1;
+            }
+            $overallStatus = 0; // Track overall command status (0 for success, 1 for any failure)
+            $maxBookId = PaliText::max('book');
+            if ($book === 0) {
+                $booksId = range(1, $maxBookId);
+            } else if ($this->option('resume')) {
+                $booksId = range($book, $maxBookId);
+            } else {
+                $booksId = [$book];
+            }
+            foreach ($booksId as $key => $bookId) {
+                if (
+                    $this->option('granularity') === 'chapter' ||
+                    $this->option('granularity') === 'all'
+                ) {
+                    $this->indexChapter($bookId);
+                }
+                if (
+                    $this->option('granularity') === 'paragraph' ||
+                    $this->option('granularity') === 'all'
+                ) {
+                    $this->indexTipitakaParagraph($bookId, $paragraph);
+                }
+            }
+
+            return $overallStatus;
+        } catch (\Exception $e) {
+            $this->error("Failed to index Pali data: " . $e->getMessage());
+            Log::error("Failed to index Pali data for book: $book, granularity: " . ($granularity ?: 'all'), ['error' => $e]);
+            return 1;
+        }
+    }
+
+    /**
+     * Index Pali paragraphs for a given book.
+     *
+     * @param int $book
+     * @return int
+     */
+    protected function indexTipitakaParagraph($book, $paragraph = null)
+    {
+        $this->info("Starting to index paragraphs for book: $book");
+        $total = 0;
+        if ($paragraph) {
+            $paragraphs = PaliText::where('book', $book)
+                ->where('paragraph', $paragraph)
+                ->orderBy('paragraph')->cursor();
+        } else {
+            $paragraphs = PaliText::where('book', $book)
+                ->orderBy('paragraph')->cursor();
+        }
+        $bookUid = PaliText::where('book', $book)->where('level', 1)->first()->uid;
+        $category = $this->tagService->getTagsName($bookUid);
+        $headings = [];
+        $currChapterTitle = '';
+        $commentaryId = '';
+        $currSession = [];
+        foreach ($paragraphs as $key => $para) {
+            $total++;
+            if ($para->level < 8) {
+                $currChapterTitle = $para->toc;
+            }
+            if ($para->class === 'nikaya') {
+                $nikaya = $para->text;
+            }
+            $paraContent = $this->searchPaliDataService
+                ->getParaContent($para['book'], $para['paragraph']);
+            if (!empty($commentaryId)) {
+                $currSession[] = $paraContent;
+            }
+            if (isset($paraContent['commentary'])) {
+                if (!empty($commentaryId)) {
+                    //保存 session
+                    $this->indexPaliSession($para->toArray(), $currSession, $currChapterTitle, $commentaryId);
+                    $currSession = [];
+                }
+                $commentaryId = $paraContent['commentary'];
+            }
+            $this->indexParagraph($para->toArray(), $paraContent, $commentaryId, $category);
+            $this->info("{$para['book']}-[{$para['paragraph']}]-[{$commentaryId}]");
+        }
+
+        $this->info("Successfully indexed $total paragraphs for book: $book");
+        Log::info("Indexed $total paragraphs for book: $book");
+
+        return 0;
+    }
+    /**
+     *
+     */
+    protected function indexParagraph($paraInfo, $paraContent, $related_id, array $category)
+    {
+        $paraId = $paraInfo['book'] . '-' . $paraInfo['paragraph'];
+        $resource_id = $paraInfo['uid'];
+        $path = json_decode($paraInfo['path']);
+        if (is_array($path) && count($path) > 0) {
+            $title = end($path)->title;
+        } else {
+            $title = '';
+        }
+        $document = [
+            'id' => "tipitaka_paragraph_pi_{$paraId}",
+            'resource_id' => $resource_id, // Use uid from getPaliData for resource_id
+            'resource_type' => 'tipitaka',
+            'title' => [
+                'text' => ['pali' => $title,],
+            ],
+            'summary' => [
+                'text' => $this->summary  ? $this->summaryService->summarize($paraContent['markdown']) : ''
+            ],
+            'content' => [
+                'text' => ['pali' => $paraContent['text']],
+                'suggest' => ['pali' => $paraContent['words']],
+            ],
+            'bold_single' => implode(' ', $paraContent['bold1']),
+            'bold_multi' => implode(' ', array_merge($paraContent['bold2'], $paraContent['bold3'])),
+            'related_id' => $paraId,
+            'category' => $category, // Assuming Pali paragraphs are sutta; adjust as needed
+            'language' => 'pi',
+            'updated_at' => now()->toIso8601String(),
+            'granularity' => 'paragraph',
+            'path' => $this->getPathTitle($path),
+        ];
+        if ($paraInfo['level'] < 8) {
+            $document['title']['suggest']['pali'] = $paraContent['words'];
+        }
+        if ($this->isTest) {
+            $this->info($document['title']['text']['pali']);
+            $this->info($document['summary']['text']);
+        } else {
+            $this->openSearchService->create($document['id'], $document);
+        }
+        return;
+    }
+
+    /**
+     *
+     */
+    protected function indexPaliSession($paraInfo, $contents, $currChapter, $related_id)
+    {
+        $markdown = [];
+        $text = [];
+        $bold_single = [];
+        $bold_multi = [];
+        foreach ($contents as $key => $content) {
+            $markdown[] = $content['markdown'];
+            $text[] = $content['text'];
+            $bold_single = array_merge($bold_single, $content['bold1']);
+            $bold_multi = array_merge($bold_multi, $content['bold2'], $content['bold3']);
+        }
+        $document = [
+            'id' => "pali_session_{$related_id}",
+            'resource_id' => $paraInfo['uid'], // Use uid from getPaliData for resource_id
+            'resource_type' => 'original_text',
+            'title' => [
+                'pali' => "{$currChapter} paragraph {$paraInfo['paragraph']}"
+            ],
+            'summary' => [
+                'text' => $this->summary ? $this->summaryService->summarize($content['markdown']) : ''
+            ],
+            'content' => [
+                'pali' => implode("\n\n", $markdown),
+            ],
+            'bold_single' => implode(" ", $bold_single),
+            'bold_multi' => implode(" ", $bold_multi),
+            'related_id' => $related_id,
+            'category' => 'pali', // Assuming Pali paragraphs are sutta; adjust as needed
+            'language' => 'pali',
+            'updated_at' => now()->toIso8601String(),
+            'granularity' => 'session',
+            'path' => $this->getPathTitle(json_decode($paraInfo['path'])),
+        ];
+        if ($this->isTest) {
+            $this->info($document['title']['pali']);
+            $this->info($document['summary']['text']);
+        } else {
+            $this->openSearchService->create($document['id'], $document);
+        }
+        return;
+    }
+
+
+
+    /**
+     * Index Pali suttas for a given book (placeholder for future implementation).
+     *
+     * @param int $book
+     * @return int
+     */
+    protected function indexChapter($book)
+    {
+        $this->info("Starting to index paragraphs for book: $book");
+        $total = 0;
+        $chapters = PaliText::where('book', $book)
+            ->where('level', '<', 8)
+            ->orderBy('paragraph')->get();
+        foreach ($chapters as $key => $chapter) {
+            if ($chapter->level === 1) {
+                $category = $this->tagService->getTagsName($chapter->uid);
+            }
+            /**
+             * 章节的起始位置算法
+             * 从章节的标题,到下一个章节的标题之间
+             */
+            $start = $chapter->paragraph;
+            if ($key === count($chapters) - 1) {
+                $end = PaliText::where('book', $book)
+                    ->orderBy('paragraph', 'desc')->first()
+                    ->value('paragraph');
+            } else {
+                $end = $chapters[$key + 1]->paragraph;
+            }
+            //获取这个段落之间的全部channel
+            $channels = Sentence::where('book_id', $book)
+                ->whereBetween('paragraph', [$start, $end])
+                ->select('channel_uid')
+                ->groupBy('channel_uid')->get();
+            $this->info("index chapter start={$start} end={$end}");
+
+            foreach ($channels as $key => $channel) {
+                $display = [];
+                $content = [];
+                $channelInfo = ChannelApi::getById($channel->channel_uid);
+                $this->info('channel =' . $channelInfo['name']);
+                if ($channelInfo['type'] === 'wbw') {
+                    $this->info('wbw channel skip');
+                    continue;
+                }
+                $paragraphsData = app(PaliContentService::class)->paragraphs(
+                    $book,
+                    $start,
+                    $end,
+                    [$channel->channel_uid],
+                    ['mode' => 'read', 'format' => 'html', 'original' => true]
+                );
+                //生成html数据
+
+                $title = '';
+                foreach ($paragraphsData as $key => $paragraph) {
+                    $translation = [];
+                    $original = [];
+                    foreach ($paragraph['children'] as $key => $sent) {
+                        if (isset($sent['translation'])) {
+                            foreach ($sent['translation'] as $key => $tran) {
+                                $curr = $tran['html'] ?? $tran['content'];
+                                $translation[] = "<span class='sentence'>{$curr}</span>";
+                                if ($tran['para'] === $start && !empty($curr)) {
+                                    $title = $curr;
+                                }
+                            }
+                        }
+                        if (
+                            isset($sent['origin']) ||
+                            is_array($sent['origin']) ||
+                            count($sent['origin']) > 0
+                        ) {
+                            $ori = $sent['origin'][0];
+                            $curr = $ori['html'] ?? $ori['content'];
+                            $original[] = "<span class='sentence origin'>{$curr}</span>";
+                            if (empty($title) && $ori['para'] === $start && !empty($curr)) {
+                                $title = $curr;
+                            }
+                        }
+                    }
+
+
+                    $level = $paragraph['para'] === $start ? $chapter->level : 0;
+                    $strOriginal = implode('', $original);
+                    $strTranslation = implode('', $translation);
+
+                    if ($level > 0) {
+                        $display[] = "<div><h{$level}>{$strOriginal}</h{$level}><h{$level}>{$strTranslation}</h{$level}></div>";
+                    } else {
+                        $display[] = "<div><p>{$strOriginal}</p><p>{$strTranslation}</p></div>";
+                    }
+
+                    if ($channelInfo['type'] === 'original') {
+                        $content[] = $strOriginal;
+                    } else {
+                        $content[] = $strTranslation;
+                    }
+                }
+                $this->chapterSave([
+                    'book' => $book,
+                    'para' => $start,
+                    'level' => $chapter->level,
+                    'channel' => $channel->channel_uid,
+                    'display' => implode('', $display),
+                    'content' => implode('', $content),
+                    'title' => strip_tags($title),
+                    'cat' => $category
+                ]);
+            }
+        }
+
+
+        return 0;
+    }
+
+    protected function chapterSave(array $param)
+    {
+        $progress = ProgressChapter::where('book', $param['book'])
+            ->where('para', $param['para'])
+            ->where('channel_id', $param['channel'])
+            ->first();
+        $channel = ChannelApi::getById($param['channel']);
+        $document = [
+            'id'            => "tipitaka_chapter_{$param['book']}-{$param['para']}_{$param['channel']}",
+            'resource_id'   => $progress ? $progress->uid : "{$param['book']}-{$param['para']}_{$param['channel']}",
+            'resource_type' => 'tipitaka',
+            'title'         => [],
+            'summary' => [
+                'text' => '',
+            ],
+            'content'     => [],
+            'related_id'  => "{$param['book']}-{$param['para']}",
+            'category'    => $param['cat'],
+            'language'    => $channel['lang'],
+            'updated_at'  => now()->toIso8601String(),
+            'granularity' => $param['level'] === 1 ? 'book' : 'chapter',
+        ];
+
+        // TODO: 补充语言判断,将内容放入对应的 text.pali 或 text.zh 字段
+        $plainText = strip_tags($param['content']);
+        $title = strip_tags($param['title']);
+        if (str_contains($channel['lang'], 'zh')) {
+            $document['content']['text']['zh'] = $plainText;
+            $document['title']['text']['zh'] = $title;
+        } else {
+            $document['content']['text']['pali'] = $plainText;
+            $document['title']['text']['pali'] = $title;
+        }
+        $document['content']['display']    = $param['display'];             // 展示
+
+        if ($this->isTest) {
+            $this->info($param['content']);
+        } else {
+            $this->openSearchService->create($document['id'], $document);
+            $this->info("create index {$document['id']} size=" . strlen($param['content']));
+        }
+    }
+    /**
+     * Index Pali sentences for a given book (placeholder for future implementation).
+     *
+     * @param int $book
+     * @return int
+     */
+    protected function indexPaliSentences($book)
+    {
+        $this->warn("Sentence indexing is not yet implemented for book: $book");
+        Log::warning("Sentence indexing not implemented for book: $book");
+        return 1;
+    }
+
+
+    private function getPathTitle(array $input)
+    {
+        $output = [];
+        foreach ($input as $key => $node) {
+            $output[] = $node->title;
+        }
+        return implode('/', $output);
+    }
+}

+ 93 - 0
api-v13/app/Console/Commands/InitCommentary.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\Tag;
+use App\Models\TagMap;
+use App\Models\PaliText;
+use App\Models\PaliSentence;
+use App\Models\Commentary;
+use App\Models\RelatedParagraph;
+
+class InitCommentary extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan init:commentary
+     * @var string
+     */
+    protected $signature = 'init:commentary {--book=}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'init commentary sentences';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        //查询注释书标签
+        $tags = Tag::whereIn('name', ['aṭṭhakathā', 'ṭīkā'])->select('id')->get();
+        //查询段落编号
+        $paliId = TagMap::whereIn('tag_id', $tags)
+            ->where('table_name', 'pali_texts')
+            ->cursor();
+        foreach ($paliId as $key => $paraId) {
+            $book = PaliText::where('uid', $paraId->anchor_id)
+                ->where('level', 1)->first();
+            if (!$book) {
+                continue;
+            }
+            $paragraphs = PaliText::where('book', $book->book)
+                ->whereBetween('paragraph', [$book->paragraph, $book->paragraph + $book->chapter_len - 1])
+                ->get();
+            foreach ($paragraphs as $key => $para) {
+                $this->info($para->book . '-' . $para->paragraph);
+                $sentences = PaliSentence::where('book', $para->book)
+                    ->where('paragraph', $para->paragraph)
+                    ->get();
+                $del = Commentary::where('book1', $para->book)
+                    ->where('paragraph1', $para->paragraph)
+                    ->where('owner_id', config("mint.admin.root_uuid"))
+                    ->delete();
+                $csPara = RelatedParagraph::where('book', $para->book)
+                    ->where('para', $para->paragraph)
+                    ->first();
+                if ($csPara) {
+                    foreach ($sentences as $key => $sentence) {
+                        $new = new Commentary();
+                        $new->book1 = $sentence->book;
+                        $new->paragraph1 = $sentence->paragraph;
+                        $new->start1 = $sentence->word_begin;
+                        $new->end1 = $sentence->word_end;
+                        $new->editor_id = config("mint.admin.root_uuid");
+                        $new->owner_id = config("mint.admin.root_uuid");
+                        $new->p_number = $csPara->book_name . '-' . $csPara->para;
+                        $new->save();
+                    }
+                } else {
+                    $this->error('no relation paragraph');
+                }
+            }
+        }
+        $this->info('all done');
+        return 0;
+    }
+}

+ 156 - 0
api-v13/app/Console/Commands/InitCs6sentence.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\PaliSentence;
+use App\Models\WbwTemplate;
+use App\Models\Sentence;
+use Illuminate\Support\Str;
+use App\Http\Api\ChannelApi;
+
+
+class InitCs6sentence extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'init:cs6sentence {book?} {para?}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '按照分句数据库,填充cs6的巴利原文句子';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+        $start = time();
+        $channelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
+        if ($channelId === false) {
+            $this->error('no channel');
+            return 1;
+        }
+        $this->info($channelId);
+        $pali = new PaliSentence;
+        if (!empty($this->argument('book'))) {
+            $pali = $pali->where('book', $this->argument('book'));
+        }
+        if (!empty($this->argument('para'))) {
+            $pali = $pali->where('paragraph', $this->argument('para'));
+        }
+        $bar = $this->output->createProgressBar($pali->count());
+        $pali = $pali->select('book', 'paragraph', 'word_begin', 'word_end')->cursor();
+        $pageHead = ['M', 'P', 'T', 'V', 'O'];
+        foreach ($pali as $value) {
+            # code...
+            $words = WbwTemplate::where("book", $value->book)
+                ->where("paragraph", $value->paragraph)
+                ->where("wid", ">=", $value->word_begin)
+                ->where("wid", "<=", $value->word_end)
+                ->orderBy('wid', 'asc')
+                ->get();
+            $sent = '';
+            $boldStart = false;
+            $boldCount = 0;
+            $lastWord = null;
+            foreach ($words as $word) {
+                # code...
+                //if($word->style != "note" && $word->type != '.ctl.')
+                if ($word->type != '.ctl.') {
+                    if ($lastWord !== null) {
+                        if ($word->real !== "ti") {
+
+                            if (!(empty($word->real) && empty($lastWord->real))) {
+                                #如果不是标点符号,在词的前面加空格 。
+                                $sent .= " ";
+                            }
+                        }
+                    }
+
+                    if (strpos($word->word, '{') !== false) {
+                        //一个单词里面含有黑体字的
+                        $paliWord = \str_replace("{", "<strong>", $word->word);
+                        $paliWord = \str_replace("}", "</strong>", $paliWord);
+                        $sent .= $paliWord;
+                    } else {
+                        if ($word->style == 'bld') {
+                            $sent .= "<strong>{$word->word}</strong>";
+                        } else {
+                            $sent .= $word->word;
+                        }
+                    }
+                } else {
+                    $type = substr($word->word, 0, 1);
+                    if (in_array($type, $pageHead)) {
+                        $arrPage = explode('.', $word->word);
+                        if (count($arrPage) === 2) {
+                            $pageNumber = $arrPage[0] . '.' . (int)$arrPage[1];
+                            $sent .= "<code>{$pageNumber}</code>";
+                        }
+                    }
+                }
+                $lastWord = $word;
+            }
+
+            #将wikipali风格的引用 改为缅文风格
+            /*
+			$sent = \str_replace('n’’’ ti','’’’nti',$sent);
+			$sent = \str_replace('n’’ ti','’’nti',$sent);
+			$sent = \str_replace('n’ ti','’nti',$sent);
+			$sent = \str_replace('**ti**','**ti',$sent);
+			$sent = \str_replace('‘ ','‘',$sent);
+            */
+            $sent = \str_replace(' ti', 'ti', $sent);
+
+            $newRow = Sentence::firstOrNew(
+                [
+                    "book_id" => $value->book,
+                    "paragraph" => $value->paragraph,
+                    "word_start" => $value->word_begin,
+                    "word_end" => $value->word_end,
+                    "channel_uid" => $channelId,
+                ],
+                [
+                    'id' => app('snowflake')->id(),
+                    'uid' => Str::uuid(),
+                    'create_time' => time() * 1000,
+                ]
+            );
+            $newRow->editor_uid = config("mint.admin.root_uuid");
+            $newRow->content = "<span>{$sent}</span>";
+            $newRow->strlen = mb_strlen($sent, "UTF-8");
+            $newRow->status = 10;
+            $newRow->content_type = "html";
+            $newRow->modify_time = time() * 1000;
+            $newRow->language = 'en';
+            $newRow->save();
+
+            $bar->advance();
+        }
+        $bar->finish();
+        $this->info("finished " . (time() - $start) . "s");
+        return 0;
+    }
+}

+ 58 - 0
api-v13/app/Console/Commands/InitDependence.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Symfony\Component\Process\Process;
+use Symfony\Component\Process\Exception\ProcessFailedException;
+
+class InitDependence extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'init:dep';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'init dependence date - pali sencence ect.';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+		#克隆依赖的数据仓库到本地
+		$depDir = $this->info(config("mint.path.dependence"));
+		foreach ($this->info(config("mint.dependence")) as $key => $value) {
+			# code...
+			$process = new Process(['git','clone',$value->url,$depDir.'/'.$value->path]);
+			$process->run();
+			if(!$process->isSuccessful()){
+				throw new ProcessFailedException($process);
+			}
+			$this->info($process->getOutput());
+		}
+        return 0;
+    }
+}

+ 148 - 0
api-v13/app/Console/Commands/InitSystemChannel.php

@@ -0,0 +1,148 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Channel;
+use Illuminate\Console\Command;
+
+class InitSystemChannel extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'init:system.channel';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'create system channel. like pali text , wbw template ect.';
+
+    protected $channels = [
+        [
+            "name" => '_System_Pali_VRI_',
+            'type' => 'original',
+            'lang' => 'pali',
+        ],
+        [
+            "name" => '_System_Wbw_VRI_',
+            'type' => 'wbw',
+            'lang' => 'pali',
+        ],
+        [
+            "name" => '_System_Grammar_Term_zh-hans_',
+            'type' => 'translation',
+            'lang' => 'zh-Hans',
+        ],
+        [
+            "name" => '_System_Grammar_Term_zh-hant_',
+            'type' => 'translation',
+            'lang' => 'zh-Hant',
+        ],
+        [
+            "name" => '_System_Grammar_Term_en_',
+            'type' => 'translation',
+            'lang' => 'en',
+        ],
+        [
+            "name" => '_System_Grammar_Term_my_',
+            'type' => 'translation',
+            'lang' => 'my',
+        ],
+        [
+            "name" => '_community_term_zh-hans_',
+            'type' => 'translation',
+            'lang' => 'zh-Hans',
+        ],
+        [
+            "name" => '_community_term_zh-hant_',
+            'type' => 'translation',
+            'lang' => 'zh-Hant',
+        ],
+        [
+            "name" => '_community_term_en_',
+            'type' => 'translation',
+            'lang' => 'en',
+        ],
+        [
+            "name" => '_community_translation_zh-hans_',
+            'type' => 'translation',
+            'lang' => 'zh-Hans',
+        ],
+        [
+            "name" => '_community_translation_zh-hant_',
+            'type' => 'translation',
+            'lang' => 'zh-Hant',
+        ],
+        [
+            "name" => '_community_translation_en_',
+            'type' => 'translation',
+            'lang' => 'en',
+        ],
+        [
+            "name" => '_System_Quote_',
+            'type' => 'original',
+            'lang' => 'en',
+        ],
+        [
+            "name" => '_community_summary_zh-hans_',
+            'type' => 'translation',
+            'lang' => 'zh-Hans',
+        ],
+        [
+            "name" => '_System_commentary_',
+            'type' => 'commentary',
+            'lang' => 'en',
+            'status' => 30,
+        ],
+    ];
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+        $this->info("start");
+        foreach ($this->channels as $key => $value) {
+            # code...
+            $channel = Channel::firstOrNew([
+                'name' => $value['name'],
+                'owner_uid' => config("mint.admin.root_uuid"),
+            ]);
+            if (empty($channel->id)) {
+                $channel->id = app('snowflake')->id();
+            }
+            $channel->type = $value['type'];
+            $channel->lang = $value['lang'];
+            $channel->editor_id = 0;
+            $channel->owner_uid = config("mint.admin.root_uuid");
+            $channel->create_time = time() * 1000;
+            $channel->modify_time = time() * 1000;
+            $channel->is_system = true;
+            if (isset($value['status'])) {
+                $channel->status = $value['status'];
+            }
+            $channel->save();
+            $this->info("created" . $value['name']);
+        }
+        return 0;
+    }
+}

+ 103 - 0
api-v13/app/Console/Commands/InitSystemDict.php

@@ -0,0 +1,103 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\DictInfo;
+use Illuminate\Console\Command;
+
+class InitSystemDict extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     * php artisan init:system.dict
+     */
+    protected $signature = 'init:system.dict';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'create system dict. like sys_regular  ect.';
+
+    /**
+     * name 不要修改。因为在程序其他地方,用name 查询词典id
+     */
+    protected $dictionary =[
+        [
+            "name"=>'robot_compound',
+            'shortname'=>'compound',
+            'description'=>'split compound by AI',
+            'src_lang'=>'pa',
+            'dest_lang'=>'cm',
+        ],
+        [
+            "name"=>'system_regular',
+            'shortname'=>'regular',
+            'description'=>'system regular',
+            'src_lang'=>'pa',
+            'dest_lang'=>'cm',
+        ],
+        [
+            "name"=>'community',
+            'shortname'=>'社区',
+            'description'=>'由用户贡献词条的社区字典',
+            'src_lang'=>'pa',
+            'dest_lang'=>'cm',
+        ],
+        [
+            "name"=>'community_extract',
+            'shortname'=>'社区汇总',
+            'description'=>'由用户贡献词条的社区字典汇总统计',
+            'src_lang'=>'pa',
+            'dest_lang'=>'cm',
+        ],
+        [
+            "name"=>'system_preference',
+            'shortname'=>'系统单词首选项',
+            'description'=>'通过系统筛选出的首选项,只包含语法信息',
+            'src_lang'=>'pa',
+            'dest_lang'=>'cm',
+        ],
+    ];
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+        $this->info("start");
+        foreach ($this->dictionary as $key => $value) {
+            # code...
+            $channel = DictInfo::firstOrNew([
+                'name' => $value['name'],
+                'owner_id' => config("mint.admin.root_uuid"),
+            ]);
+            $channel->shortname = $value['shortname'];
+            $channel->description = $value['description'];
+            $channel->src_lang = $value['src_lang'];
+            $channel->dest_lang = $value['dest_lang'];
+            $channel->meta = json_encode($value,JSON_UNESCAPED_UNICODE);
+            $channel->save();
+            $this->info("updated {$value['name']}");
+        }
+        return 0;
+    }
+}

+ 66 - 0
api-v13/app/Console/Commands/Install.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+
+class Install extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'install {--test}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'install new host';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+
+    public function handle()
+    {
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+		$isTest = $this->option('test');
+		if($isTest){
+			$this->call('install:wbwtemplate', ['from' => 1]);
+		}else{
+			$this->call('install:wbwtemplate');
+			$this->call('install:palitext');
+			$this->call('install:wordbook');
+			$this->call('install:wordall');
+			$this->call('install:wordindex');
+
+			$this->call('upgrade:palitext');
+			$this->call('upgrade:palitoc',['lang'=>'pali']);
+			$this->call('upgrade:palitoc',['lang'=>'zh-hans']);
+			$this->call('upgrade:palitoc',['lang'=>'zh-hant']);
+
+			$this->call('install:paliseries');
+			$this->call('install:wordstatistics');
+
+		}
+
+        return 0;
+    }
+}

+ 42 - 0
api-v13/app/Console/Commands/InstallPaliSent.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+
+class InstallPaliSent extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'command:name';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        return 0;
+    }
+}

+ 83 - 0
api-v13/app/Console/Commands/InstallPaliSeries.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\BookTitle;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class InstallPaliSeries extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'install:pali.series';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+		$this->info("upgrade pali serieses");
+		$startTime = time();
+
+		DB::transaction(function () {
+			#删除目标数据库中数据
+			BookTitle::where('book','>',0)->delete();
+
+		// 打开csv文件并读取数据
+			$strFileName = config("mint.path.pali_title") . "/pali_serieses.csv";
+			if(!file_exists($strFileName)){
+				return 1;
+			}
+			$inputRow = 0;
+			if (($fp = fopen($strFileName, "r")) !== false) {
+				while (($data = fgetcsv($fp, 0, ',')) !== false) {
+					if($inputRow>0){
+						$newData = [
+							'sn'=>$data[0],
+							'book'=>$data[1],
+							'paragraph'=>$data[2],
+							'title'=>$data[3],
+						];
+
+						BookTitle::create($newData);
+					}
+					$inputRow++;
+				}
+				fclose($fp);
+				Log::info("res load:" .$strFileName);
+			} else {
+				$this->error("can not open csv $strFileName");
+				Log::error("can not open csv $strFileName");
+			}
+		});
+		$this->info("ok");
+        return 0;
+    }
+}

+ 42 - 0
api-v13/app/Console/Commands/InstallPaliSim.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+
+class InstallPaliSim extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'command:name';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        return 0;
+    }
+}

+ 156 - 0
api-v13/app/Console/Commands/InstallPaliText.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\PaliText;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class InstallPaliText extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'install:palitext {from?} {to?}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+		$this->info("instert pali text");
+		$startTime = time();
+
+		$_from = $this->argument('from');
+		$_to = $this->argument('to');
+		if(empty($_from) && empty($_to)){
+			$_from = 1;
+			$_to = 217;
+		}else if(empty($_to)){
+			$_to = $_from;
+		}
+		$fileListFileName = config("mint.path.palitext_filelist");
+
+		$filelist = array();
+
+		if (($handle = fopen($fileListFileName, 'r')) !== false) {
+			while (($filelist[] = fgetcsv($handle, 0, ',')) !== false) {
+			}
+		}
+		$bar = $this->output->createProgressBar($_to-$_from+1);
+
+		for ($from=$_from; $from <=$_to ; $from++) {
+			# code...
+
+			$fileSn = $from-1;
+			$FileName = $filelist[$fileSn][1];
+
+			$dirXmlBase = config("mint.path.palicsv") . "/";
+			$GLOBALS['data'] = array();
+
+			// 打开vri html文件并读取数据
+			$pali_text_array = array();
+			$htmlFile = config("mint.path.palitext") .'/'. $FileName.'.htm';
+			if (($fpPaliText = fopen($htmlFile, "r")) !== false) {
+				while (($data = fgets($fpPaliText)) !== false) {
+					if (substr($data, 0, 2) === "<p") {
+						array_push($pali_text_array, $data);
+					}
+				}
+				fclose($fpPaliText);
+				//$this->info("pali text load:" . $htmlFile . PHP_EOL);
+			} else {
+				$this->error( "can not pali text file. filename=" . $htmlFile . PHP_EOL) ;
+				Log::error( "can not pali text file. filename=" . $htmlFile . PHP_EOL) ;
+			}
+
+			$inputRow = 0;
+			$csvFile = config("mint.path.palicsv") .'/'. $FileName .'/'. $FileName.'_pali.csv';
+			if (($fp = fopen($csvFile, "r")) !== false) {
+				while (($data = fgetcsv($fp, 0, ',')) !== false) {
+					if ($inputRow > 0) {
+						if (($inputRow - 1) < count($pali_text_array)) {
+							$data[5] = $pali_text_array[$inputRow - 1];
+						}
+						$data[1] = mb_substr($data[1],1,null,"UTF-8");
+						$GLOBALS['data'][] = $data;
+					}
+					$inputRow++;
+				}
+				fclose($fp);
+				//$this->info("单词表load:" . $csvFile.PHP_EOL);
+			} else {
+				$this->error( "can not open csv file. filename=" . $csvFile. PHP_EOL) ;
+				Log::error( "can not open csv file. filename=" . $csvFile. PHP_EOL) ;
+				continue;
+			}
+
+			if (($inputRow - 1) != count($pali_text_array)) {
+				$this->error( "line count error $FileName ".PHP_EOL);
+				Log::error( "line count error $FileName ".PHP_EOL);
+			}
+
+
+			#删除目标数据库中数据
+			PaliText::where('book', $from)->delete();
+
+
+			// 打开文件并读取数据
+
+
+			DB::transaction(function () {
+				foreach ($GLOBALS['data'] as $oneParam) {
+					if ($oneParam[3] < 100) {
+						$toc = $oneParam[6];
+					} else {
+						$toc = "";
+					}
+					$params = [
+						'book'=>$oneParam[1],
+						'paragraph'=>$oneParam[2],
+						'level'=>$oneParam[3],
+						'class'=> $oneParam[4],
+						'toc'=>$toc,
+						'text'=>$oneParam[6],
+						'html'=>$oneParam[5],
+						'lenght'=>mb_strlen($oneParam[6], "UTF-8"),
+					];
+					PaliText::create($params);
+				}
+
+			});
+
+			$bar->advance();
+		}
+		$bar->finish();
+		$this->info("instert pali text finished. in ". time()-$startTime . "s" .PHP_EOL);
+
+        return 0;
+
+    }
+}

+ 118 - 0
api-v13/app/Console/Commands/InstallWbwTemplate.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\WbwTemplate;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class InstallWbwTemplate extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'install:wbwtemplate {from?} {to?}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if(\App\Tools\Tools::isStop()){
+            return 0;
+        }
+		$this->info("instert wbw template");
+
+
+		$_from = $this->argument('from');
+		$_to = $this->argument('to');
+		if(empty($_from) && empty($_to)){
+			$_from = 1;
+			$_to = 217;
+		}else if(empty($_to)){
+			$_to = $_from;
+		}
+		$fileListFileName = public_path('/palihtml/filelist.csv');
+
+		$filelist = array();
+
+		if (($handle = fopen($fileListFileName, 'r')) !== false) {
+			while (($filelist[] = fgetcsv($handle, 0, ',')) !== false) {
+			}
+		}
+		$bar = $this->output->createProgressBar($_to-$_from+1);
+
+		for ($from=$_from; $from <=$_to ; $from++) {
+			# code...
+
+			$fileSn = $from-1;
+			$outputFileNameHead = $filelist[$fileSn][1];
+
+			$dirXmlBase = public_path('/tmp/palicsv') . "/";
+			$dirXml = $outputFileNameHead . "/";
+
+
+			#删除目标数据库中数据
+			WbwTemplate::where('book', $from)->delete();
+
+
+			// 打开文件并读取数据
+
+			if (($GLOBALS["fp"] = fopen($dirXmlBase . $dirXml . $outputFileNameHead . ".csv", "r")) !== false) {
+				$GLOBALS["row"]=0;
+				DB::transaction(function () {
+					while (($data = fgetcsv($GLOBALS["fp"], 0, ',')) !== false) {
+						$GLOBALS["row"]++;
+						if($GLOBALS["row"]==1){
+							continue;
+						}
+						#或略第一行 标题行
+						$params = [
+							'book'=>mb_substr($data[2], 1),
+							'paragraph'=>$data[3],
+							'wid'=>$data[16],
+							'word'=>$data[4],
+							'real'=>$data[5],
+							'type'=>$data[6],
+							'gramma'=>$data[7],
+							'part'=>$data[10],
+							'style'=>$data[15]
+						];
+						WbwTemplate::insert($params);
+					}
+				});
+				fclose($GLOBALS["fp"]);
+			} else {
+				$this->error("can not open csv file. filename=" . $dirXmlBase . $dirXml . $outputFileNameHead . ".csv".PHP_EOL) ;
+				Log::error("can not open csv file. filename=" . $dirXmlBase . $dirXml . $outputFileNameHead . ".csv".PHP_EOL) ;
+			}
+
+			$bar->advance();
+		}
+		$bar->finish();
+        return 0;
+
+	}
+}

Some files were not shown because too many files changed in this diff