visuddhinanda hace 2 meses
padre
commit
3e74538cf7
Se han modificado 100 ficheros con 11958 adiciones y 0 borrados
  1. 168 0
      api-v12/app/Console/Commands/AiTranslate.php
  2. 77 0
      api-v12/app/Console/Commands/CacheDictPreference.php
  3. 116 0
      api-v12/app/Console/Commands/CacheWbwPreference.php
  4. 45 0
      api-v12/app/Console/Commands/ClearEmbeddingsCache.php
  5. 180 0
      api-v12/app/Console/Commands/CopyUserBook.php
  6. 114 0
      api-v12/app/Console/Commands/CreateMyHanCrop.php
  7. 89 0
      api-v12/app/Console/Commands/CreateOpenSearchIndex.php
  8. 100 0
      api-v12/app/Console/Commands/ExportAiPaliWordToken.php
  9. 114 0
      api-v12/app/Console/Commands/ExportAiTrainingData.php
  10. 162 0
      api-v12/app/Console/Commands/ExportArticle.php
  11. 87 0
      api-v12/app/Console/Commands/ExportChannel.php
  12. 311 0
      api-v12/app/Console/Commands/ExportChapter.php
  13. 92 0
      api-v12/app/Console/Commands/ExportChapterIndex.php
  14. 69 0
      api-v12/app/Console/Commands/ExportCreateDb.php
  15. 109 0
      api-v12/app/Console/Commands/ExportFtsPali.php
  16. 68 0
      api-v12/app/Console/Commands/ExportIKPaliTeam.php
  17. 219 0
      api-v12/app/Console/Commands/ExportNissaya.php
  18. 126 0
      api-v12/app/Console/Commands/ExportOffline.php
  19. 97 0
      api-v12/app/Console/Commands/ExportPaliSynonyms.php
  20. 87 0
      api-v12/app/Console/Commands/ExportPalitext.php
  21. 131 0
      api-v12/app/Console/Commands/ExportSentence.php
  22. 80 0
      api-v12/app/Console/Commands/ExportTag.php
  23. 75 0
      api-v12/app/Console/Commands/ExportTagmap.php
  24. 100 0
      api-v12/app/Console/Commands/ExportTerm.php
  25. 203 0
      api-v12/app/Console/Commands/ExportZip.php
  26. 147 0
      api-v12/app/Console/Commands/ImportArticle.php
  27. 213 0
      api-v12/app/Console/Commands/ImportArticleMap.php
  28. 289 0
      api-v12/app/Console/Commands/IndexPaliText.php
  29. 93 0
      api-v12/app/Console/Commands/InitCommentary.php
  30. 156 0
      api-v12/app/Console/Commands/InitCs6sentence.php
  31. 58 0
      api-v12/app/Console/Commands/InitDependence.php
  32. 148 0
      api-v12/app/Console/Commands/InitSystemChannel.php
  33. 103 0
      api-v12/app/Console/Commands/InitSystemDict.php
  34. 66 0
      api-v12/app/Console/Commands/Install.php
  35. 42 0
      api-v12/app/Console/Commands/InstallPaliSent.php
  36. 83 0
      api-v12/app/Console/Commands/InstallPaliSeries.php
  37. 42 0
      api-v12/app/Console/Commands/InstallPaliSim.php
  38. 156 0
      api-v12/app/Console/Commands/InstallPaliText.php
  39. 118 0
      api-v12/app/Console/Commands/InstallWbwTemplate.php
  40. 95 0
      api-v12/app/Console/Commands/InstallWordAll.php
  41. 107 0
      api-v12/app/Console/Commands/InstallWordBook.php
  42. 89 0
      api-v12/app/Console/Commands/InstallWordIndex.php
  43. 91 0
      api-v12/app/Console/Commands/InstallWordStatistics.php
  44. 360 0
      api-v12/app/Console/Commands/MqAiTranslate.php
  45. 313 0
      api-v12/app/Console/Commands/MqDiscussion.php
  46. 61 0
      api-v12/app/Console/Commands/MqEmpty.php
  47. 71 0
      api-v12/app/Console/Commands/MqExport.php
  48. 75 0
      api-v12/app/Console/Commands/MqExportArticle.php
  49. 74 0
      api-v12/app/Console/Commands/MqExportPaliChapter.php
  50. 53 0
      api-v12/app/Console/Commands/MqIssues.php
  51. 174 0
      api-v12/app/Console/Commands/MqPr.php
  52. 70 0
      api-v12/app/Console/Commands/MqProgress.php
  53. 57 0
      api-v12/app/Console/Commands/MqTask.php
  54. 63 0
      api-v12/app/Console/Commands/MqWbwAnalyses.php
  55. 44 0
      api-v12/app/Console/Commands/PatchWbwPageNumber.php
  56. 92 0
      api-v12/app/Console/Commands/ProcessDeadLetterQueue.php
  57. 313 0
      api-v12/app/Console/Commands/RabbitMQWorker.php
  58. 61 0
      api-v12/app/Console/Commands/RemoveTermCache.php
  59. 80 0
      api-v12/app/Console/Commands/StatisticsDict.php
  60. 77 0
      api-v12/app/Console/Commands/StatisticsExp.php
  61. 90 0
      api-v12/app/Console/Commands/StatisticsNissaya.php
  62. 144 0
      api-v12/app/Console/Commands/StatisticsNissayaCover.php
  63. 77 0
      api-v12/app/Console/Commands/StatisticsWbw.php
  64. 60 0
      api-v12/app/Console/Commands/TestAI.php
  65. 55 0
      api-v12/app/Console/Commands/TestAiTask.php
  66. 69 0
      api-v12/app/Console/Commands/TestCaseMan.php
  67. 53 0
      api-v12/app/Console/Commands/TestJsonToXml.php
  68. 83 0
      api-v12/app/Console/Commands/TestMarkdownToTpl.php
  69. 200 0
      api-v12/app/Console/Commands/TestMdRender.php
  70. 68 0
      api-v12/app/Console/Commands/TestMq.php
  71. 46 0
      api-v12/app/Console/Commands/TestMqExit.php
  72. 131 0
      api-v12/app/Console/Commands/TestMqWorker.php
  73. 126 0
      api-v12/app/Console/Commands/TestProjectCopyTask.php
  74. 48 0
      api-v12/app/Console/Commands/TestSchedule.php
  75. 77 0
      api-v12/app/Console/Commands/TestSearchPali.php
  76. 102 0
      api-v12/app/Console/Commands/TestTex.php
  77. 76 0
      api-v12/app/Console/Commands/TestWorkerStartProject.php
  78. 62 0
      api-v12/app/Console/Commands/UpdateRelationTo.php
  79. 89 0
      api-v12/app/Console/Commands/UpdateSentenceUnique.php
  80. 58 0
      api-v12/app/Console/Commands/UpdateSentenceVer.php
  81. 327 0
      api-v12/app/Console/Commands/UpgradeAITranslation.php
  82. 60 0
      api-v12/app/Console/Commands/UpgradeAt20230227.php
  83. 173 0
      api-v12/app/Console/Commands/UpgradeChapterDynamic.php
  84. 163 0
      api-v12/app/Console/Commands/UpgradeChapterDynamicWeekly.php
  85. 178 0
      api-v12/app/Console/Commands/UpgradeCommunityTerm.php
  86. 341 0
      api-v12/app/Console/Commands/UpgradeCompound.php
  87. 106 0
      api-v12/app/Console/Commands/UpgradeDaily.php
  88. 227 0
      api-v12/app/Console/Commands/UpgradeDict.php
  89. 146 0
      api-v12/app/Console/Commands/UpgradeDictDefaultMeaning.php
  90. 62 0
      api-v12/app/Console/Commands/UpgradeDictId.php
  91. 145 0
      api-v12/app/Console/Commands/UpgradeDictSysPreference.php
  92. 156 0
      api-v12/app/Console/Commands/UpgradeDictSysRegular.php
  93. 139 0
      api-v12/app/Console/Commands/UpgradeDictSysWbwExtract.php
  94. 64 0
      api-v12/app/Console/Commands/UpgradeDictVocabulary.php
  95. 149 0
      api-v12/app/Console/Commands/UpgradeFts.php
  96. 141 0
      api-v12/app/Console/Commands/UpgradeGrammarBook.php
  97. 75 0
      api-v12/app/Console/Commands/UpgradePageNumber.php
  98. 253 0
      api-v12/app/Console/Commands/UpgradePaliText.php
  99. 75 0
      api-v12/app/Console/Commands/UpgradePaliTextId.php
  100. 111 0
      api-v12/app/Console/Commands/UpgradePaliTextTag.php

+ 168 - 0
api-v12/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-v12/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 App\Tools\RedisClusters;
+
+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);
+                RedisClusters::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;
+    }
+}

+ 116 - 0
api-v12/app/Console/Commands/CacheWbwPreference.php

@@ -0,0 +1,116 @@
+<?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;
+use App\Tools\RedisClusters;
+
+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');
+                RedisClusters::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-v12/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-v12/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-v12/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');
+        }
+    }
+}

+ 89 - 0
api-v12/app/Console/Commands/CreateOpenSearchIndex.php

@@ -0,0 +1,89 @@
+<?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...');
+                try {
+                    $update = $openSearch->updateIndex();
+                    if (!empty($update['settings']) && $update['settings']['acknowledged']) {
+                        $this->info('Index settings updated successfully');
+                    }
+                    if (!empty($update['mappings']) && $update['mappings']['acknowledged']) {
+                        $this->info('Index mappings updated successfully');
+                    }
+                    if (empty($update['settings']) && empty($update['mappings'])) {
+                        $this->warn('No settings or mappings provided for update');
+                    }
+                } catch (\Exception $updateException) {
+                    $this->error('Failed to update index: ' . $updateException->getMessage());
+                    return 1;
+                }
+            } else {
+                $this->error('Failed to create index: ' . $e->getMessage());
+                return 1;
+            }
+        }
+        return 0;
+    }
+}

+ 100 - 0
api-v12/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;
+    }
+}

+ 114 - 0
api-v12/app/Console/Commands/ExportAiTrainingData.php

@@ -0,0 +1,114 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use App\Models\Sentence;
+use App\Models\PaliSentence;
+use Illuminate\Support\Str;
+use App\Http\Api\MdRender;
+
+class ExportAiTrainingData extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'export:ai.training.data {--format=gz  : zip file format 7z,lzma,gz }';
+
+    /**
+     * 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::debug('task export offline sentence-table start');
+        //创建文件夹
+        $exportDir = storage_path('app/tmp/export/offline');
+        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);
+            }
+        }
+        $filename = 'wikipali-offline-ai-training-' . date("Y-m-d") . '.tsv';
+        $exportFile = storage_path('app/tmp/export/offline/' . $filename);
+        $fp = fopen($exportFile, 'w');
+        if ($fp === false) {
+            die('无法创建文件');
+        }
+
+        $channels = [
+            '19f53a65-81db-4b7d-8144-ac33f1217d34',
+        ];
+        $start = time();
+        foreach ($channels as $key => $channel) {
+            $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'
+            ])->cursor();
+            foreach ($srcDb as $sent) {
+                $content = MdRender::render(
+                    $sent->content,
+                    [$channel],
+                    null,
+                    'read',
+                    'translation',
+                    $sent->content_type,
+                    'text',
+                );
+                $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');
+                $currData = array(
+                    str_replace("\n", "", $origin),
+                    str_replace("\n", "", $content),
+                );
+
+                fwrite($fp, implode("\t", $currData) . "\n");
+
+                $bar->advance();
+            }
+        }
+        fclose($fp);
+        $this->info((time() - $start) . ' seconds');
+        $this->call('export:zip', [
+            'id' => 'ai-translating-training-data',
+            'filename' => $exportFile,
+            'title' => 'wikipali ai translating training data',
+            'format' => $this->option('format'),
+        ]);
+        return 0;
+    }
+}

+ 162 - 0
api-v12/app/Console/Commands/ExportArticle.php

@@ -0,0 +1,162 @@
+<?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 App\Tools\RedisClusters;
+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-v12/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;
+    }
+}

+ 311 - 0
api-v12/app/Console/Commands/ExportChapter.php

@@ -0,0 +1,311 @@
+<?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 App\Tools\RedisClusters;
+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;
+    }
+}

+ 92 - 0
api-v12/app/Console/Commands/ExportChapterIndex.php

@@ -0,0 +1,92 @@
+<?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;
+use App\Tools\RedisClusters;
+
+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();
+        RedisClusters::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-v12/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');
+    }
+}

+ 109 - 0
api-v12/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;
+    }
+}

+ 68 - 0
api-v12/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-v12/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,''];
+    }
+}

+ 126 - 0
api-v12/app/Console/Commands/ExportOffline.php

@@ -0,0 +1,126 @@
+<?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 App\Tools\RedisClusters;
+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
+        RedisClusters::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;
+    }
+}

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

@@ -0,0 +1,97 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\DictApi;
+use App\Models\UserDict;
+use App\Models\DhammaTerm;
+use Illuminate\Support\Facades\Redis;
+use Illuminate\Support\Facades\Log;
+
+class ExportPaliSynonyms extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan export:pali.synonyms
+     * @var string
+     */
+    protected $signature = 'export:pali.synonyms';
+
+    /**
+     * 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()
+    {
+        //irregular
+        $dictId = ['4d3a0d92-0adc-4052-80f5-512a2603d0e8'];
+        //regular
+        $dictId[] = DictApi::getSysDict('system_regular');
+        $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_synonyms.txt";
+        $fp = fopen($path . $filename, 'w') or die("Unable to open file!");
+        foreach ($dictId as $key => $dict) {
+            $parents = UserDict::where('dict_id', $dict)
+                ->select('parent')
+                ->groupBy('parent')->cursor();
+
+            foreach ($parents as $key => $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-v12/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-v12/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-v12/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-v12/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-v12/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-v12/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 App\Tools\RedisClusters;
+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 = RedisClusters::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' => RedisClusters::get("/export/chapter/count"),
+            'filesize' => filesize($zipFullFileName),
+            'min_app_ver' => '1.3',
+        ];
+        RedisClusters::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;
+    }
+}

+ 147 - 0
api-v12/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-v12/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;
+    }
+}

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

@@ -0,0 +1,289 @@
+<?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;
+    }
+}

+ 93 - 0
api-v12/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-v12/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-v12/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-v12/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' => 'original',
+            '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-v12/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-v12/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-v12/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-v12/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-v12/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-v12/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-v12/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;
+
+	}
+}

+ 95 - 0
api-v12/app/Console/Commands/InstallWordAll.php

@@ -0,0 +1,95 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\WordList;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class InstallWordAll extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'install:wordall {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;
+        }
+		$startTime = time();
+
+		$this->info("instert word in palibook ");
+		Log::info("instert word in palibook ");
+
+		$_from = $this->argument('from');
+		$_to = $this->argument('to');
+		if(empty($_from) && empty($_to)){
+			$_from = 1;
+			$_to = 217;
+		}else if(empty($_to)){
+			$_to = $_from;
+		}
+
+		$bar = $this->output->createProgressBar($_to-$_from+1);
+
+		for ($book=$_from; $book <= $_to; $book++) {
+			Log::info("doing ".($book));
+			DB::transaction(function ()use($book) {
+				$fileSn = $book-1;
+				if (($fpoutput = fopen(config("mint.path.paliword_book") . "/{$fileSn}_words.csv", "r")) !== false){
+					#删除目标数据库中数据
+					WordList::where('book', $book)->delete();
+					while (($data = fgetcsv($fpoutput, 0, ',')) !== false)
+					{
+						$newData = [
+							'sn'=>$data[0],
+							'book'=>$data[1],
+							'paragraph'=>$data[2],
+							'wordindex'=>$data[3],
+							'bold'=>$data[4],
+						];
+						WordList::create($newData);
+					}
+					return 0;
+				}else{
+					Log::error("open csv fail");
+					return 1;
+				}
+			});
+			$bar->advance();
+		}
+		$bar->finish();
+
+		$msg = "all done in ". time()-$startTime . "s";
+		$this->info($msg.PHP_EOL);
+		Log::info($msg);
+        return 0;
+    }
+}

+ 107 - 0
api-v12/app/Console/Commands/InstallWordBook.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\BookWord;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class InstallWordBook extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'install:wordbook {from?} {to?}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'install palibook word list in each book';
+
+    /**
+     * 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;
+        }
+		$startTime = time();
+
+		$this->info("instert word in palibook ");
+		Log::info("instert word in palibook ");
+
+		$_from = $this->argument('from');
+		$_to = $this->argument('to');
+		if(empty($_from) && empty($_to)){
+			$_from = 1;
+			$_to = 217;
+		}else if(empty($_to)){
+			$_to = $_from;
+		}
+
+		$bar = $this->output->createProgressBar($_to-$_from+1);
+
+		for ($book=$_from; $book <= $_to; $book++) {
+			Log::info("doing ".($book));
+
+			#删除目标数据库中数据
+			BookWord::where('book', $book)->delete();
+
+			//分类汇总得到单词表
+			$bookword = array();
+			$fileId = $book-1;
+			if (($fpoutput = fopen(config("mint.path.paliword_book") . "/{$fileId}_words.csv", "r")) !== false) {
+				$count = 0;
+				while (($data = fgetcsv($fpoutput, 0, ',')) !== false) {
+					$book = $data[1];
+					if (isset($bookword[$data[3]])) {
+						$bookword[$data[3]]++;
+					} else {
+						$bookword[$data[3]] = 1;
+					}
+
+					$count++;
+				}
+			}else{
+				Log::error("open csv fail");
+				continue;
+			}
+			DB::transaction(function ()use($book,$bookword) {
+				foreach ($bookword as $key => $value) {
+					$newData = [
+						'book'=>$book,
+						'wordindex'=>$key,
+						'count'=>$value,
+					];
+					BookWord::create($newData);
+				}
+			});
+			$bar->advance();
+		}
+		$bar->finish();
+
+		$msg = "all done in ". time()-$startTime . "s";
+		$this->info($msg.PHP_EOL);
+		Log::info($msg);
+
+        return 0;
+    }
+}

+ 89 - 0
api-v12/app/Console/Commands/InstallWordIndex.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\WordIndex;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class InstallWordIndex extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'install:wordindex';
+
+    /**
+     * 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;
+        }
+		$startTime = time();
+
+		$info = "instert word in palibook ";
+		$this->info($info);
+		Log::info($info);
+
+		#删除目标数据库中数据
+		WordIndex::where('id', '>',-1)->delete();
+
+		$scan = scandir(config("mint.path.paliword_index"));
+		$bar = $this->output->createProgressBar(count($scan));
+		foreach($scan as $filename) {
+			$bar->advance();
+			$filename = config("mint.path.paliword_index")."/".$filename;
+			if (is_file($filename)) {
+				Log::info("doing ".$filename);
+				DB::transaction(function ()use($filename) {
+				if (($fpoutput = fopen($filename, "r")) !== false) {
+						$count = 0;
+						while (($data = fgetcsv($fpoutput, 0, ',')) !== false) {
+							$newData = [
+								'id'=>$data[0],
+								'word'=>$data[1],
+								'word_en'=>$data[2],
+								'normal'=>$data[3],
+								'bold'=>$data[4],
+								'is_base'=>$data[5],
+								'len'=>$data[6],
+							];
+							WordIndex::create($newData);
+							$count++;
+						}
+						Log::info("insert ".$count);
+					}
+				});
+			}
+		}
+		$bar->finish();
+		$msg = "all done in ". time()-$startTime . "s";
+		Log::info($msg);
+		$this->info($msg);
+        return 0;
+    }
+}

+ 91 - 0
api-v12/app/Console/Commands/InstallWordStatistics.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\WordStatistic;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class InstallWordStatistics extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'install:wordstatistics';
+
+    /**
+     * 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;
+        }
+		$startTime = time();
+
+		$info = "instert wordstatistics ";
+		$this->info($info.PHP_EOL);
+		Log::info($info);
+
+		#删除目标数据库中数据
+		WordStatistic::where('id', '>',-1)->delete();
+
+		$scan = scandir(config("mint.path.word_statistics"));
+		$bar = $this->output->createProgressBar(count($scan));
+		foreach($scan as $filename) {
+			$bar->advance();
+			$filename = config("mint.path.word_statistics")."/".$filename;
+			if (is_file($filename)) {
+				Log::info("doing ".$filename);
+				DB::transaction(function ()use($filename) {
+				if (($fpoutput = fopen($filename, "r")) !== false) {
+						$count = 0;
+						while (($data = fgetcsv($fpoutput, 0, ',')) !== false) {
+							$newData = [
+								'bookid'=>$data[0],
+								'word'=>$data[1],
+								'count'=>$data[2],
+								'base'=>$data[3],
+								'end1'=>$data[4],
+								'end2'=>$data[5],
+								'type'=>$data[6],
+								'length'=>$data[7],
+							];
+							WordStatistic::create($newData);
+							$count++;
+						}
+						Log::info("insert ".$count);
+					}
+				});
+			}
+		}
+		$bar->finish();
+		$msg = "all done in ". time()-$startTime . "s";
+		Log::info($msg);
+		$this->info($msg);
+        return 0;
+        return 0;
+    }
+}

+ 360 - 0
api-v12/app/Console/Commands/MqAiTranslate.php

@@ -0,0 +1,360 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\Mq;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Http\Client\RequestException;
+use App\Tools\RedisClusters;
+
+class MqAiTranslate extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan mq:ai.translate
+     * @var string
+     */
+    protected $signature = 'mq:ai.translate';
+
+    /**
+     * 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;
+        }
+        $exchange = 'router';
+        $queue = 'ai_translate';
+        $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
+        Log::debug("mq worker {$queue} start.");
+        Mq::worker($exchange, $queue, function ($messages, $messageId) use ($queue) {
+            Log::debug('ai translate start', ['message' => count($messages)]);
+            $this->info('ai translate task start task=' . count($messages));
+            if (!is_array($messages) || count($messages) === 0) {
+                Log::error('message is not array');
+                return 1;
+            }
+
+            //获取model token
+            $first = $messages[0];
+            $taskId = $first->task->info->id;
+            RedisClusters::put("/task/{$taskId}/message_id", $messageId);
+            $pointerKey = "/message/{$messageId}/pointer";
+            $pointer = 0;
+            if (RedisClusters::has($pointerKey)) {
+                //回到上次中断的点
+                $pointer = RedisClusters::get($pointerKey);
+            }
+
+            Log::debug($queue . ' ai assistant token', ['user' => $first->model->uid]);
+            $modelToken = $first->model->token;
+            Log::debug($queue . ' ai assistant token', ['token' => $modelToken]);
+
+            $this->setTaskStatus($first->task->info->id, 'running', $modelToken);
+
+            $discussionUrl = config('app.url') . '/api/v2/discussion';
+            $taskDiscussionData = [
+                'res_id' => $first->task->info->id,
+                'res_type' => 'task',
+                'title' => $first->task->info->title,
+                'content' => $first->task->info->category,
+                'content_type' => 'markdown',
+                'type' => 'discussion',
+                'notification' => false,
+            ];
+            $response = Http::timeout(10)->withToken($modelToken)->post($discussionUrl, $taskDiscussionData);
+            if ($response->failed()) {
+                Log::error($queue . ' discussion create topic error', ['data' => $response->json()]);
+            } else {
+                if (isset($response->json()['data']['id'])) {
+                    $taskDiscussionData['parent'] = $response->json()['data']['id'];
+                }
+            }
+
+            for ($i = $pointer; $i < count($messages); $i++) {
+                RedisClusters::put($pointerKey, $i);
+                $message = $messages[$i];
+                $taskDiscussionContent = [];
+                $param = [
+                    "model" => $message->model->model,
+                    "messages" => [
+                        ["role" => "system", "content" => $message->model->system_prompt ?? ''],
+                        ["role" => "user", "content" => $message->prompt],
+                    ],
+                    "temperature" => 0.7,
+                    "stream" => false
+                ];
+                Log::info($queue . ' LLM request' . $message->model->url);
+                Log::info($queue . ' model:' . $param['model']);
+                Log::debug($queue . ' LLM api request', [
+                    'url' => $message->model->url,
+                    'data' => $param
+                ]);
+
+                //写入 model log
+                $modelLogData = [
+                    'model_id' => $message->model->uid,
+                    'request_at' => now(),
+                    'request_data' => json_encode($param, JSON_UNESCAPED_UNICODE),
+                ];
+
+                try {
+                    $response = Http::withToken($message->model->key)
+                        ->timeout(300)
+                        ->post($message->model->url, $param);
+
+                    $response->throw(); // 触发异常(如果请求失败)
+                    $taskDiscussionContent[] = '- LLM request successful';
+                    Log::info($queue . ' LLM request successful');
+
+                    $modelLogData['request_headers'] = json_encode($response->handlerStats(), JSON_UNESCAPED_UNICODE);
+                    $modelLogData['response_headers'] = json_encode($response->headers(), JSON_UNESCAPED_UNICODE);
+                    $modelLogData['status'] = $response->status();
+                    $modelLogData['response_data'] = json_encode($response->json(), JSON_UNESCAPED_UNICODE);
+                    self::saveModelLog($modelToken, $modelLogData);
+                    /*
+                if ($response->failed()) {
+                    $modelLog->success = false;
+                    $modelLog->save();
+                    Log::error($queue . ' http response error', ['data' => $response->json()]);
+                    return 1;
+                }*/
+                } catch (RequestException $e) {
+                    Log::error($queue . ' LLM request exception: ' . $e->getMessage());
+                    $failResponse = $e->response;
+
+                    $modelLogData['request_headers'] = json_encode($failResponse->handlerStats(), JSON_UNESCAPED_UNICODE);
+                    $modelLogData['response_headers'] = json_encode($failResponse->headers(), JSON_UNESCAPED_UNICODE);
+                    $modelLogData['status'] = $failResponse->status();
+                    $modelLogData['response_data'] = $response->body();
+                    $modelLogData['success'] = false;
+                    self::saveModelLog($modelToken, $modelLogData);
+                    continue;
+                }
+                Log::info($queue . ' model log saved');
+
+                $aiData = $response->json();
+                Log::debug($queue . ' LLM http response', ['data' => $response->json()]);
+                $responseContent = $aiData['choices'][0]['message']['content'];
+                if (isset($aiData['choices'][0]['message']['reasoning_content'])) {
+                    $reasoningContent = $aiData['choices'][0]['message']['reasoning_content'];
+                }
+
+                Log::debug($queue . ' LLM response content=' . $responseContent);
+                if (empty($reasoningContent)) {
+                    Log::debug($queue . ' no reasoningContent');
+                } else {
+                    Log::debug($queue . ' reasoning=' . $reasoningContent);
+                }
+
+
+
+                if ($message->task->info->category === 'translate') {
+                    //写入句子库
+                    $url = config('app.url') . '/api/v2/sentence';
+                    $sentData = [];
+                    $message->sentence->content = $responseContent;
+                    $sentData[] = $message->sentence;
+                    Log::info($queue . " sentence update {$url}");
+                    $response = Http::timeout(10)->withToken($modelToken)->post($url, [
+                        'sentences' => $sentData,
+                    ]);
+                    if ($response->failed()) {
+                        Log::error($queue . ' sentence update failed', [
+                            'url' => $url,
+                            'data' => $response->json(),
+                        ]);
+                        continue;
+                    } else {
+                        $count = $response->json()['data']['count'];
+                        Log::info("{$queue} sentence update {$count} successful");
+                    }
+                }
+                if ($message->task->info->category === 'suggest') {
+                    //写入pr
+                    $url = config('app.url') . '/api/v2/sentpr';
+                    Log::info($queue . " sentence update {$url}");
+                    $response = Http::timeout(10)->withToken($modelToken)->post($url, [
+                        'book' => $message->sentence->book_id,
+                        'para' => $message->sentence->paragraph,
+                        'begin' => $message->sentence->word_start,
+                        'end' => $message->sentence->word_end,
+                        'channel' => $message->sentence->channel_uid,
+                        'text' => $responseContent,
+                        'notification' => false,
+                        'webhook' => false,
+                    ]);
+                    if ($response->failed()) {
+                        Log::error($queue . ' sentence update failed', [
+                            'url' => $url,
+                            'data' => $response->json(),
+                        ]);
+                        continue;
+                    } else {
+                        if ($response->json()['ok']) {
+                            Log::info("{$queue} sentence suggest update successful");
+                        } else {
+                            Log::error("{$queue} sentence suggest update failed", [
+                                'url' => $url,
+                                'data' => $response->json(),
+                            ]);
+                        }
+                    }
+                }
+
+                //写入discussion
+                #获取句子id
+                $url = config('app.url') . '/api/v2/sentence-info/aa';
+                Log::info('ai translate', ['url' => $url]);
+                $response = Http::timeout(10)->withToken($modelToken)->get($url, [
+                    'book' => $message->sentence->book_id,
+                    'par' => $message->sentence->paragraph,
+                    'start' => $message->sentence->word_start,
+                    'end' => $message->sentence->word_end,
+                    'channel' => $message->sentence->channel_uid
+                ]);
+                if ($response->json()['ok']) {
+                    $sUid = $response->json()['data']['id'];
+                } else {
+                    Log::error($queue . ' sentence id error', ['data' => $response->json()]);
+                    return 1;
+                }
+                $url = config('app.url') . '/api/v2/discussion';
+                $data = [
+                    'res_id' => $sUid,
+                    'res_type' => 'sentence',
+                    'title' => $message->task->info->title,
+                    'content' => $message->task->info->category,
+                    'content_type' => 'markdown',
+                    'type' => 'discussion',
+                    'notification' => false,
+                ];
+                $response = Http::timeout(10)->withToken($modelToken)->post($url, $data);
+                if ($response->failed()) {
+                    Log::error($queue . ' discussion create topic error', ['data' => $response->json()]);
+                } else {
+                    if (isset($response->json()['data']['id'])) {
+                        Log::info($queue . ' discussion create topic successful');
+                        $data['parent'] = $response->json()['data']['id'];
+                        unset($data['title']);
+                        $topicChildren = [];
+                        //提示词
+                        $topicChildren[] = $message->prompt;
+                        //任务结果
+                        $topicChildren[] = $responseContent;
+                        //推理过程写入discussion
+                        if (isset($reasoningContent) && !empty($reasoningContent)) {
+                            $topicChildren[] = $reasoningContent;
+                        }
+                        foreach ($topicChildren as  $content) {
+                            $data['content'] = $content;
+                            Log::debug($queue . ' discussion child request', ['url' => $url, 'data' => $data]);
+                            $response = Http::timeout(10)->withToken($modelToken)->post($url, $data);
+                            if ($response->failed()) {
+                                Log::error($queue . ' discussion error', ['data' => $response->json()]);
+                            } else {
+                                Log::info($queue . ' discussion child successful');
+                            }
+                        }
+                    } else {
+                        Log::error($queue . ' discussion create topic response is null');
+                    }
+                }
+
+
+                //修改task 完成度
+                $taskProgress = $message->task->progress;
+                if ($taskProgress->total > 0) {
+                    $progress = (int)($taskProgress->current * 100 / $taskProgress->total);
+                } else {
+                    $progress = 100;
+                    Log::error($queue . ' progress total is zero', ['task_id' => $message->task->info->id]);
+                }
+                $url = config('app.url') . '/api/v2/task/' . $message->task->info->id;
+                $data = [
+                    'progress' => $progress,
+                ];
+                Log::debug($queue . ' task progress request', ['url' => $url, 'data' => $data]);
+                $response = Http::timeout(10)->withToken($modelToken)->patch($url, $data);
+                if ($response->failed()) {
+                    Log::error($queue . ' task progress error', ['data' => $response->json()]);
+                } else {
+                    $taskDiscussionContent[] = "- progress=" . $response->json()['data']['progress'];
+                    Log::info($queue . ' task progress successful progress=' . $response->json()['data']['progress']);
+                }
+
+                if (isset($taskDiscussionData['parent'])) {
+                    unset($taskDiscussionData['title']);
+                    $taskDiscussionData['content'] = implode('\n', $taskDiscussionContent);
+                    Log::debug($queue . ' task discussion child request', ['url' => $discussionUrl, 'data' => $data]);
+                    $response = Http::timeout(10)->withToken($modelToken)->post($discussionUrl, $taskDiscussionData);
+                    if ($response->failed()) {
+                        Log::error($queue . ' task discussion error', ['data' => $response->json()]);
+                    } else {
+                        Log::info($queue . ' task discussion child successful');
+                    }
+                } else {
+                    Log::error('no task discussion root');
+                }
+
+                //任务完成 修改任务状态为 done
+                if ($progress === 100) {
+                    $this->setTaskStatus($message->task->info->id, 'done', $modelToken);
+                }
+            }
+            RedisClusters::forget($pointerKey);
+            $this->info('ai translate task complete');
+            return 0;
+        });
+        return 0;
+    }
+    private function setTaskStatus($taskId, $status, $token)
+    {
+        $url = config('app.url') . '/api/v2/task-status/' . $taskId;
+        $data = [
+            'status' => $status,
+        ];
+        Log::debug('ai_translate task status request', ['url' => $url, 'data' => $data]);
+        $response = Http::timeout(10)->withToken($token)->patch($url, $data);
+        //判断状态码
+        if ($response->failed()) {
+            Log::error('ai_translate task status error', ['data' => $response->json()]);
+        } else {
+            Log::info('ai_translate task status done');
+        }
+    }
+
+    private function saveModelLog($token, $data)
+    {
+        $url = config('app.url') . '/api/v2/model-log';
+
+        $response = Http::timeout(10)->withToken($token)->post($url, $data);
+        if ($response->failed()) {
+            Log::error('ai-translate model log create failed', ['data' => $response->json()]);
+            return false;
+        }
+        return true;
+    }
+}

+ 313 - 0
api-v12/app/Console/Commands/MqDiscussion.php

@@ -0,0 +1,313 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+
+use App\Models\Sentence;
+use App\Models\WebHook;
+use App\Models\Discussion;
+use App\Models\Article;
+use App\Models\DhammaTerm;
+use App\Models\Wbw;
+use App\Models\WbwBlock;
+use App\Http\Api\Mq;
+use App\Tools\WebHook as WebHookSend;
+use App\Http\Api\MdRender;
+use App\Http\Api\UserApi;
+use App\Http\Controllers\NotificationController;
+
+
+class MqDiscussion extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan mq:discussion
+     * @var string
+     */
+    protected $signature = 'mq:discussion';
+
+    /**
+     * 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;
+        }
+        $exchange = 'router';
+        $queue = 'discussion';
+        $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
+        Log::info("discussion worker start .");
+        Mq::worker($exchange, $queue, function ($message) {
+            Log::info('mq discussion receive {message}', ['message' => json_encode($message, JSON_UNESCAPED_UNICODE)]);
+            $result = 0;
+            $msgParam = array();
+            $msgParam['nickname'] = $message->editor->nickName;
+            $link = config('app.url') . "/pcd/discussion/topic/";
+            if ($message->parent) {
+                $msgParam['topic-title'] = Discussion::where('id', $message->parent)->value('title');
+                $id = $message->id;
+                $msgParam['link'] = $link . $message->parent . '#' . $id;
+                $msgParam['card_title'] = "回复讨论";
+                $type = 'reply';
+            } else {
+                $msgParam['title'] = $message->title;
+                $msgParam['link'] = $link . $message->id;
+                $msgParam['card_title'] = "创建讨论";
+                $type = 'create';
+            }
+            if ($message->content) {
+                $msgParam['content'] = $message->content;
+            }
+
+            switch ($message->res_type) {
+                case 'sentence':
+                    $sentence = Sentence::where('uid', $message->res_id)->first();
+                    if (!$sentence) {
+                        Log::error('invalid sentence id ' . $message->res_id);
+                        $result = 1;
+                        break;
+                    }
+
+                    //站内信
+                    try {
+                        $sendTo = array();
+                        //句子的channel拥有者
+                        //$sendTo[] = $prData->channel->studio_id;
+                        //句子的作者
+                        if (!in_array($sentence->editor_uid, $sendTo)) {
+                            $sendTo[] = $sentence->editor_uid;
+                        }
+                        //句子的采纳者
+                        if (!empty($sentence->acceptor_uid) && !in_array($sentence->acceptor_uid, $sendTo)) {
+                            $sendTo[] = $sentence->acceptor_uid;
+                        }
+                        $this->notification(
+                            $message->editor->id,
+                            $sendTo,
+                            'discussion',
+                            $message->id,
+                            $sentence->channel_uid
+                        );
+                    } catch (\Exception $e) {
+                        Log::error('send notification failed', ['exception' => $e]);
+                    }
+
+                    //webhook
+                    $contentHtml = MdRender::render(
+                        $sentence->content,
+                        [$sentence->channel_uid],
+                        null,
+                        'read',
+                        'translation',
+                        $sentence->content_type
+                    );
+                    $contentTxt = strip_tags($contentHtml);
+                    /**生成消息内容 */
+
+                    $msgParam['anchor-content'] = $contentTxt;
+                    $WebHookResId = $sentence->channel_uid;
+
+                    $this->WebHook($msgParam, $type, $WebHookResId);
+                    break;
+                case 'wbw':
+                    $wbw = Wbw::where('uid', $message->res_id)->first();
+                    if (!$wbw) {
+                        Log::error('invalid wbw id ' . $message->res_id);
+                        $result = 1;
+                        break;
+                    }
+                    $wbwBlock = WbwBlock::where('uid', $wbw->block_uid)->first();
+                    if (!$wbwBlock) {
+                        Log::error('invalid wbw-block id ' . $message->res_id);
+                        $result = 1;
+                        break;
+                    }
+
+                    //站内信
+                    try {
+                        $sendTo = array();
+                        //channel拥有者
+                        //$sendTo[] = $prData->channel->studio_id;
+                        //作者
+                        if (!in_array($wbw->creator_uid, $sendTo)) {
+                            $sendTo[] = $wbw->creator_uid;
+                        }
+                        //提问者
+                        if (!empty($message->parent)) {
+                            $topicEditor = Discussion::where('id', $message->parent)
+                                ->value('editor_uid');
+                            if (!empty($topicEditor) && !in_array($topicEditor, $sendTo)) {
+                                $sendTo[] = $topicEditor;
+                                Log::debug('发送给提问者', ['data' => $topicEditor]);
+                            }
+                        }
+
+                        $this->notification(
+                            $message->editor->id,
+                            $sendTo,
+                            'discussion',
+                            $message->id,
+                            $wbwBlock->channel_uid
+                        );
+                    } catch (\Exception $e) {
+                        Log::error('send notification failed', ['exception' => $e]);
+                    }
+
+                    $msgParam['anchor-content'] = $wbw->word;
+                    $WebHookResId = $wbwBlock->channel_uid;
+                    $this->WebHook($msgParam, $type, $WebHookResId);
+                    break;
+                case 'term':
+                    $term = DhammaTerm::where('guid', $message->res_id)->first();
+                    if (!$term) {
+                        Log::error('invalid term id ' . $message->res_id);
+                        $result = 1;
+                        break;
+                    }
+                    if (empty($term->channal) || !Str::isUuid($term->channal)) {
+                        break;
+                    }
+
+
+                    //站内信
+                    try {
+                        $sendTo = array();
+                        //拥有者
+                        $sendTo[] = $term->term;
+                        //作者
+                        $editor = UserApi::getById($term->editor_id);
+                        if ($editor['id'] !== 0 && !in_array($editor['id'], $sendTo)) {
+                            $sendTo[] = $editor['id'];
+                        }
+                        $this->notification(
+                            $message->editor->id,
+                            $sendTo,
+                            'discussion',
+                            $message->id,
+                            $term->channal
+                        );
+                    } catch (\Exception $e) {
+                        Log::error('send notification failed', ['exception' => $e]);
+                    }
+                    //webhook
+                    $msgParam['anchor-content'] = $term->meaning . '(' . $term->word . ')';
+                    $WebHookResId = $term->channal;
+                    $this->WebHook($msgParam, 'term', $WebHookResId);
+
+                    break;
+                default:
+                    # code...
+                    break;
+            }
+
+            return $result;
+        });
+
+        return 0;
+    }
+
+    private function WebHook($msgParam, $type, $resId)
+    {
+        $rootId = UserApi::getById(0)['id'];
+        $articleTitle = "webhook://discussion/{$type}/zh-hans";
+        $tpl = Article::where('owner', $rootId)
+            ->where('title', $articleTitle)
+            ->value('content');
+        if (empty($tpl)) {
+            Log::error('mq:discussion 模版不能为空', ['tpl_title' => $articleTitle]);
+            return 1;
+        }
+        $m = new \Mustache_Engine(array(
+            'entity_flags' => ENT_QUOTES,
+            'delimiters' => '{% %}',
+        ));
+        $msgContent = $m->render($tpl, $msgParam);
+
+        $webhooks = WebHook::where('res_id', $resId)
+            ->where('status', 'active')
+            ->get();
+        $result = 0;
+        foreach ($webhooks as $key => $hook) {
+            $event = json_decode($hook->event);
+
+            if (is_array($event)) {
+                if (!in_array('discussion', $event)) {
+                    continue;
+                }
+            } else {
+                continue;
+            }
+            $command = '';
+            $whSend = new WebHookSend;
+            $ok = 0;
+            switch ($hook->receiver) {
+                case 'dingtalk':
+                    $ok = $whSend->dingtalk($hook->url, $msgParam['card_title'], $msgContent);
+                    break;
+                case 'wechat':
+                    $ok = $whSend->wechat($hook->url, null, $msgContent);
+                    break;
+                default:
+                    $ok = 2;
+                    break;
+            }
+            $result += $ok;
+            $logMsg = "{$command}  ok={$ok}";
+            if ($ok === 0) {
+                $this->info($logMsg);
+            } else {
+                $this->error($logMsg);
+            }
+
+            if ($ok === 0) {
+                Log::debug('mq:discussion: send success {url}', ['url' => $hook->url]);
+                WebHook::where('id', $hook->id)->increment('success');
+            } else {
+                Log::error('mq:discussion: send fail {url}', ['url' => $hook->url]);
+                WebHook::where('id', $hook->id)->increment('fail');
+            }
+        }
+    }
+
+    private function notification($from, $to, $resType, $resId, $channel)
+    {
+        //发送站内信
+        try {
+
+            $sendCount = NotificationController::insert(
+                $from,
+                $to,
+                $resType,
+                $resId,
+                $channel
+            );
+            $this->info("send notification success to [" . $sendCount . '] users');
+        } catch (\Exception $e) {
+            Log::error('send notification failed', ['exception' => $e]);
+        }
+        return;
+    }
+}

+ 61 - 0
api-v12/app/Console/Commands/MqEmpty.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use App\Http\Api\Mq;
+
+class MqEmpty extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'mq:empty';
+
+    /**
+     * 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;
+        }
+        $exchange = 'router';
+        $queue = 'ai_translate';
+        $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
+        Log::debug("mq worker {$queue} start.");
+        Mq::worker(
+            $exchange,
+            $queue,
+            function ($message) {
+                $this->info('new message');
+                sleep(3);
+                return 0;
+            }
+        );
+
+        return 0;
+    }
+}

+ 71 - 0
api-v12/app/Console/Commands/MqExport.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\Mq;
+use Illuminate\Support\Facades\Log;
+
+class MqExport extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan mq:export
+     * @var string
+     */
+    protected $signature = 'mq:export';
+
+    /**
+     * 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()
+    {
+        $exchange = 'router';
+        $queue = 'export';
+        $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
+        Log::debug("mq:progress start.");
+        Mq::worker($exchange,$queue,function ($message){
+            $data = [
+                        'book'=>$message->book,
+                        'para'=>$message->para,
+                        'channel'=>$message->channel,
+                        '--format'=>$message->format,
+                        'filename'=>$message->filename,
+                    ];
+            if(isset($message->origin) && is_string($message->origin)){
+                $data['--origin'] = $message->origin;
+            }
+            if(isset($message->translation) && is_string($message->translation)){
+                $data['--translation'] = $message->translation;
+            }
+            $ok = $this->call('export:chapter',$data);
+            if($ok !== 0){
+                Log::error('mq:progress upgrade:progress fail',$data);
+            }else{
+                $this->info("Received book=".$message->book.' result='.$ok);
+                Log::debug("mq:export: done ",$data);
+                return $ok;
+            }
+        });
+        return 0;
+    }
+}

+ 75 - 0
api-v12/app/Console/Commands/MqExportArticle.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\Mq;
+use Illuminate\Support\Facades\Log;
+
+class MqExportArticle extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan mq:export.article
+     * @var string
+     */
+    protected $signature = 'mq:export.article';
+
+    /**
+     * 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()
+    {
+        $exchange = 'router';
+        $queue = 'export_article';
+        $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
+        Log::debug("mq:export_article start.");
+        Mq::worker($exchange,$queue,function ($message){
+            $data = [
+                        'id'=>$message->id,
+                        '--format'=>$message->format,
+                        'query_id'=>$message->queryId,
+                        '--origin'=>$message->origin,
+                        '--translation'=>$message->translation,
+                    ];
+            if(isset($message->token) && is_string($message->token)){
+                $data['--token'] = $message->token;
+            }
+            if(isset($message->anthology) && is_string($message->anthology)){
+                $data['--anthology'] = $message->anthology;
+            }
+            if(isset($message->channel) && is_string($message->channel)){
+                $data['--channel'] = $message->channel;
+            }
+            $ok = $this->call('export:article',$data);
+            if($ok !== 0){
+                Log::error('mq:export.article fail',$data);
+            }else{
+                $this->info("Received article id=".$message->id.' result='.$ok);
+                Log::debug("mq:export.article done ",$data);
+                return $ok;
+            }
+        });
+
+        return 0;
+    }
+}

+ 74 - 0
api-v12/app/Console/Commands/MqExportPaliChapter.php

@@ -0,0 +1,74 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\Mq;
+use Illuminate\Support\Facades\Log;
+
+class MqExportPaliChapter extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan mq:export.pali.chapter
+     * @var string
+     */
+    protected $signature = 'mq:export.pali.chapter';
+
+    /**
+     * 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()
+    {
+        $exchange = 'router';
+        $queue = 'export_pali_chapter';
+        $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
+        Log::debug("mq:export_pali_chapter start.");
+        Mq::worker($exchange,$queue,function ($message){
+            $data = [
+                        'book'=>$message->book,
+                        'para'=>$message->para,
+                        'channel'=>$message->channel,
+                        '--format'=>$message->format,
+                        'query_id'=>$message->queryId,
+                    ];
+            if(isset($message->origin) && is_string($message->origin)){
+                $data['--origin'] = $message->origin;
+            }
+            if(isset($message->translation) && is_string($message->translation)){
+                $data['--translation'] = $message->translation;
+            }
+            if(isset($message->token) && is_string($message->token)){
+                $data['--token'] = $message->token;
+            }
+            $ok = $this->call('export:chapter',$data);
+            if($ok !== 0){
+                Log::error('mq:export.pali.chapter upgrade:progress fail',$data);
+            }else{
+                $this->info("Received book=".$message->book.' result='.$ok);
+                Log::debug("mq:export.pali.chapter done ",$data);
+                return $ok;
+            }
+        });
+        return 0;
+    }
+}

+ 53 - 0
api-v12/app/Console/Commands/MqIssues.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\Mq;
+
+class MqIssues extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan mq:issues
+     * @var string
+     */
+    protected $signature = 'mq:issues';
+
+    /**
+     * 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;
+        }
+        $exchange = 'router';
+        $queue = 'issues';
+        $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
+        Mq::worker($exchange,$queue,function ($message){
+            print_r($message);
+            return 0;
+        });
+        return 0;
+    }
+}

+ 174 - 0
api-v12/app/Console/Commands/MqPr.php

@@ -0,0 +1,174 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+
+use App\Http\Api\Mq;
+use App\Models\Sentence;
+use App\Models\WebHook;
+use App\Models\PaliSentence;
+use App\Tools\WebHook as WebHookSend;
+use App\Http\Api\MdRender;
+use App\Http\Api\PaliTextApi;
+use App\Http\Controllers\NotificationController;
+
+class MqPr extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan mq:pr
+     * @var string
+     */
+    protected $signature = 'mq:pr';
+
+    protected $ver = '2024-1-2';
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'push pr message to mq';
+
+    /**
+     * 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;
+        }
+        $exchange = 'router';
+        $queue = 'suggestion';
+        $this->info(" [*] Waiting for {$queue}. Ver. " . $this->ver);
+        Log::debug("mq:pr start. ver=" . $this->ver);
+        Mq::worker($exchange, $queue, function ($message) {
+            /**生成消息内容 */
+
+            $msgTitle = '修改建议';
+            $prData = $message->data;
+            $sent_num = "{$prData->book}-{$prData->paragraph}-{$prData->word_start}-{$prData->word_end}";
+            $this->info('ver=' . $this->ver . ' request' . $sent_num);
+
+            $username = $prData->editor->nickName;
+            $palitext = PaliSentence::where('book', $prData->book)
+                ->where('paragraph', $prData->paragraph)
+                ->where('word_begin', $prData->word_start)
+                ->where('word_end', $prData->word_end)
+                ->value('text');
+            $orgText = Sentence::where('book_id', $prData->book)
+                ->where('paragraph', $prData->paragraph)
+                ->where('word_start', $prData->word_start)
+                ->where('word_end', $prData->word_end)
+                ->where('channel_uid', $prData->channel->id)
+                ->first();
+            $prtext = mb_substr($prData->content, 0, 140, "UTF-8");
+
+            $link = config('app.url') . "/pcd/article/para/{$prData->book}-{$prData->paragraph}";
+            $link .= "?book={$prData->book}&par={$prData->paragraph}&channel={$prData->channel->id}";
+
+            $msgContent = "{$username} 就文句`{$palitext}`提出了修改建议:\n";
+            $msgContent .= ">内容摘要:<font color=\"comment\">{$prtext}</font>,\n";
+            $msgContent .= ">句子编号:<font color=\"info\">{$sent_num}</font>\n";
+            $msgContent .= "欢迎大家[点击链接]({$link})查看并讨论。";
+
+
+            $result = 0;
+            //发送站内信
+            if ($message->webhook) {
+
+                try {
+                    $sendTo = array();
+                    if ($prData->editor->id !== $prData->channel->studio_id) {
+                        $sendTo[] = $prData->channel->studio_id;
+                    }
+                    if ($orgText) {
+                        //原文作者
+                        if (
+                            !in_array($orgText->editor_uid, $sendTo) &&
+                            $orgText->editor_uid !== $prData->editor->id
+                        ) {
+                            $sendTo[] = $orgText->editor_uid;
+                        }
+                        //原文采纳者
+                        if (
+                            !empty($orgText->acceptor_uid) &&
+                            !in_array($orgText->acceptor_uid, $sendTo) &&
+                            $orgText->acceptor_uid !== $prData->editor->id
+                        ) {
+                            $sendTo[] = $orgText->acceptor_uid;
+                        }
+                    }
+                    if (count($sendTo) > 0) {
+                        $sendCount = NotificationController::insert(
+                            from: $prData->editor->id,
+                            to: $sendTo,
+                            res_type: 'suggestion',
+                            res_id: $prData->uid,
+                            channel: $prData->channel->id
+                        );
+                    }
+
+                    $this->info("send notification success to [" . count($sendTo) . '] users');
+                } catch (\Exception $e) {
+                    $this->error('send notification failed');
+                    Log::error('send notification failed', ['exception' => $e]);
+                }
+            }
+
+            //发送webhook
+            if ($message->webhook) {
+                $webhooks = WebHook::where('res_id', $prData->channel->id)
+                    ->where('status', 'active')
+                    ->get();
+
+
+                foreach ($webhooks as $key => $hook) {
+                    $event = json_decode($hook->event);
+                    if (!in_array('pr', $event)) {
+                        continue;
+                    }
+                    $command = '';
+                    $whSend = new WebHookSend;
+                    switch ($hook->receiver) {
+                        case 'dingtalk':
+                            $ok = $whSend->dingtalk($hook->url, $msgTitle, $msgContent);
+                            break;
+                        case 'wechat':
+                            $ok = $whSend->wechat($hook->url, null, $msgContent);
+                            break;
+                        default:
+                            $ok = 2;
+                            break;
+                    }
+                    $this->info("{$command}  ok={$ok}");
+                    $result += $ok;
+                    if ($ok === 0) {
+                        Log::debug('mq:pr: send success {url}', ['url' => $hook->url]);
+                        WebHook::where('id', $hook->id)->increment('success');
+                    } else {
+                        Log::error('mq:pr: send fail {url}', ['url' => $hook->url]);
+                        WebHook::where('id', $hook->id)->increment('fail');
+                    }
+                }
+            }
+
+            return $result;
+        });
+        return 0;
+    }
+}

+ 70 - 0
api-v12/app/Console/Commands/MqProgress.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\Mq;
+use Illuminate\Support\Facades\Log;
+
+class MqProgress extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan mq:progress
+     * @var string
+     */
+    protected $signature = 'mq:progress';
+
+    /**
+     * 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;
+        }
+        $exchange = 'router';
+        $queue = 'progress';
+        $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
+        Log::debug("mq:progress start.");
+        Mq::worker($exchange,$queue,function ($message){
+            $data = [
+                        '--book'=>$message->book,
+                        '--para'=>$message->para,
+                        '--channel'=>$message->channel,
+                    ];
+            $ok1 = $this->call('upgrade:progress',$data);
+            if($ok1 !== 0){
+                Log::error('mq:progress upgrade:progress fail',$data);
+            }
+            $ok2 = $this->call('upgrade:progress.chapter',$data);
+            if($ok2 !== 0){
+                Log::error('mq:progress upgrade:progress.chapter fail',$data);
+            }
+            $this->info("Received book=".$message->book.' progress='.$ok1.' chapter='.$ok2);
+            Log::debug("mq:progress: done book=".$message->book.' progress='.$ok1.' chapter='.$ok2);
+            return $ok1+$ok2;
+        });
+        return 0;
+
+    }
+}

+ 57 - 0
api-v12/app/Console/Commands/MqTask.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\Mq;
+use Illuminate\Support\Facades\Log;
+
+class MqTask extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'mq:task';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'run task';
+
+    /**
+     * 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('env='.env("RABBITMQ_HOST"));
+        $this->info('config='.config("queue.connections.rabbitmq.host"));
+        $exchange = 'router';
+        $queue = 'task';
+        $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
+        Mq::worker($exchange,$queue,function ($message){
+            $message = json_decode(json_encode($message), true);
+            $this->info('name=',$message['name']);
+            return $this->call($message['name'],$message['param']);
+        });
+        return 0;
+    }
+}

+ 63 - 0
api-v12/app/Console/Commands/MqWbwAnalyses.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\Mq;
+use Illuminate\Support\Facades\Log;
+
+class MqWbwAnalyses extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'mq:wbw.analyses';
+
+    /**
+     * 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;
+        }
+        $exchange = 'router';
+        $queue = 'wbw-analyses';
+        $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
+        Log::debug("mq:wbw.analyses start.");
+        Mq::worker($exchange,$queue,function ($message){
+            $data = ['id'=>implode(',',$message)];
+            $ok = $this->call('upgrade:wbw.analyses',$data);
+            if($ok === 0){
+                $this->info("Received count=".count($message).' ok='.$ok);
+                Log::debug('mq:wbw.analyses done count='.count($message));
+            }else{
+                Log::error('mq:wbw.analyses',$data);
+            }
+            return $ok;
+        });
+
+        return 0;
+    }
+}

+ 44 - 0
api-v12/app/Console/Commands/PatchWbwPageNumber.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+
+class PatchWbwPageNumber extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'command:name';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '修补vri原文件中的页码错误';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        /**
+         */
+        return 0;
+    }
+}

+ 92 - 0
api-v12/app/Console/Commands/ProcessDeadLetterQueue.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use PhpAmqpLib\Connection\AMQPStreamConnection;
+use PhpAmqpLib\Message\AMQPMessage;
+use Illuminate\Support\Facades\Log;
+
+class ProcessDeadLetterQueue extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * 查看死信队列消息
+     * php artisan rabbitmq:process-dlq orders_dlq
+     *
+     * 重新入队死信消息
+     * php artisan rabbitmq:process-dlq orders_dlq --requeue
+     *
+     * 删除死信消息
+     * php artisan rabbitmq:process-dlq orders_dlq --delete
+     * @var string
+     */
+    protected $signature = 'rabbitmq:process-dlq {dlq_name} {--requeue} {--delete}';
+    protected $description = '处理死信队列中的消息';
+
+    public function handle()
+    {
+        $dlqName = $this->argument('dlq_name');
+        $requeue = $this->option('requeue');
+        $delete = $this->option('delete');
+
+        $config = config('queue.connections.rabbitmq');
+        $connection = new AMQPStreamConnection(
+            $config['host'],
+            $config['port'],
+            $config['user'],
+            $config['password'],
+            $config['virtual_host']
+        );
+
+        $channel = $connection->channel();
+
+        $this->info("开始处理死信队列: {$dlqName}");
+
+        $messageCount = 0;
+
+        while (true) {
+            $msg = $channel->basic_get($dlqName, false);
+
+            if (!$msg) {
+                break; // 队列为空
+            }
+
+            $messageCount++;
+            $data = json_decode($msg->body, true);
+
+            $this->info("处理第 {$messageCount} 条死信消息");
+            $this->line("原始队列: " . ($data['queue'] ?? 'unknown'));
+            $this->line("失败原因: " . ($data['failure_reason'] ?? 'unknown'));
+            $this->line("失败时间: " . ($data['failed_at'] ?? 'unknown'));
+
+            if ($requeue) {
+                // 重新入队到原始队列
+                $originalQueue = $data['queue'] ?? null;
+                if ($originalQueue && isset($data['original_message'])) {
+                    $requeueMsg = new AMQPMessage(
+                        json_encode($data['original_message']),
+                        ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]
+                    );
+
+                    $channel->basic_publish($requeueMsg, '', $originalQueue);
+                    $this->info("消息已重新入队到: {$originalQueue}");
+                }
+            }
+
+            if ($delete || $requeue) {
+                $msg->ack();
+            } else {
+                // 只是查看,不删除
+                $msg->nack(false, true);
+            }
+        }
+
+        $this->info("死信队列处理完成,共处理 {$messageCount} 条消息");
+
+        $channel->close();
+        $connection->close();
+
+        return 0;
+    }
+}

+ 313 - 0
api-v12/app/Console/Commands/RabbitMQWorker.php

@@ -0,0 +1,313 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use PhpAmqpLib\Message\AMQPMessage;
+use App\Jobs\ProcessAITranslateJob;
+use App\Jobs\BaseRabbitMQJob;
+use Illuminate\Support\Facades\Log;
+use PhpAmqpLib\Exception\AMQPTimeoutException;
+use PhpAmqpLib\Wire\AMQPTable;
+use App\Services\RabbitMQService;
+use App\Exceptions\SectionTimeoutException;
+use App\Exceptions\TaskFailException;
+
+class RabbitMQWorker extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php -d memory_limit=128M artisan rabbitmq:consume ai_translate
+     * @var string
+     */
+    protected $signature = 'rabbitmq:consume {queue} {--reset-loop-count}';
+    protected $description = '消费 RabbitMQ 队列消息';
+
+    private $connection;
+    private $channel;
+    private $processedCount = 0;
+    private $maxLoopCount = 0;
+    private $queueName;
+    private $queueConfig;
+    private $shouldStop = false;
+    private $timeout = 15;
+    private $job = null;
+
+    public function handle()
+    {
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+        $this->queueName = $this->argument('queue');
+        $this->queueConfig = config("mint.rabbitmq.queues.{$this->queueName}");
+
+        if (!$this->queueConfig) {
+            $this->error("队列 {$this->queueName} 的配置不存在");
+            return 1;
+        }
+
+        $this->maxLoopCount = $this->queueConfig['max_loop_count'];
+
+        $this->info("启动 RabbitMQ Worker");
+        $this->info("队列: {$this->queueName}");
+        $this->info("最大循环次数: {$this->maxLoopCount}");
+        $this->info("重试次数: {$this->queueConfig['retry_times']}");
+        $consume = app(RabbitMQService::class);
+        try {
+            $consume->setupQueue($this->queueName);
+            $this->channel = $consume->getChannel();
+            $this->startConsuming();
+        } catch (\Exception $e) {
+            $this->error("Worker 启动失败: " . $e->getMessage());
+            Log::error("RabbitMQ Worker 启动失败", [
+                'queue' => $this->queueName,
+                'error' => $e->getMessage()
+            ]);
+            return 1;
+        } finally {
+            $this->cleanup();
+        }
+
+        return 0;
+    }
+
+    private function startConsuming()
+    {
+        $callback = function (AMQPMessage $msg) {
+            $this->processMessage($msg);
+        };
+
+        $this->channel->basic_consume(
+            $this->queueName,
+            '',     // consumer_tag
+            false,  // no_local
+            false,  // no_ack
+            false,  // exclusive
+            false,  // nowait
+            $callback
+        );
+
+        $this->info("开始消费消息... 按 Ctrl+C 退出");
+
+        // 设置信号处理
+        if (extension_loaded('pcntl')) {
+            pcntl_signal(SIGTERM, [$this, 'handleSignal']);
+            pcntl_signal(SIGINT, [$this, 'handleSignal']);
+        }
+
+        while ($this->channel->is_consuming() && !$this->shouldStop) {
+            try {
+                $this->channel->wait(null, false, $this->timeout);
+            } catch (AMQPTimeoutException $e) {
+                //忽略
+            } catch (\Exception $e) {
+                $this->error($e->getMessage());
+                throw $e;
+            }
+
+
+            if (extension_loaded('pcntl')) {
+                pcntl_signal_dispatch();
+            }
+
+            // 检查是否达到最大循环次数
+            if ($this->processedCount >= $this->maxLoopCount) {
+                $this->info("达到最大循环次数 ({$this->maxLoopCount}),Worker 自动退出");
+                break;
+            }
+            if (\App\Tools\Tools::isStop()) {
+                //检测到停止标记
+                break;
+            }
+        }
+    }
+
+    private function processMessage(AMQPMessage $msg)
+    {
+        try {
+
+            Log::info('processMessage start', ['message_id' => $msg->get('message_id')]);
+
+            $data = json_decode($msg->getBody());
+            $this->info("processMessage start " . $msg->get('message_id') . '[' . count($data) . ']');
+
+            if (json_last_error() !== JSON_ERROR_NONE) {
+                throw new \Exception("JSON 解析失败: " . json_last_error_msg());
+            }
+
+            // 获取重试次数(从消息头中获取)
+            $retryCount = 0;
+            if ($msg->has('application_headers')) {
+                $headers = $msg->get('application_headers')->getNativeData();
+                $retryCount = $headers['retry_count'] ?? 0;
+            }
+
+            // 根据队列类型创建对应的 Job
+            $this->job = $this->createJob($msg->get('message_id'), $data, $retryCount);
+
+            try {
+                // 执行业务逻辑
+                $this->job->handle();
+                // 成功处理,确认消息
+                $msg->ack();
+
+                $this->processedCount++;
+
+                $this->info("消息处理成功 [{$this->processedCount}/{$this->maxLoopCount}]");
+            } catch (SectionTimeoutException $e) {
+                $msg->nack(true, false);
+                Log::warning('attempt to requeue the message message_id:' . $msg->get('message_id'));
+            } catch (TaskFailException $e) {
+                $msg->nack(false, false);
+            } catch (\Exception $e) {
+                //requeue
+                $this->handleJobException($msg, $data, $retryCount, $e);
+            }
+        } catch (\Exception $e) {
+            $this->error("消息处理异常: " . $e->getMessage());
+            Log::error("RabbitMQ 消息处理异常", [
+                'queue' => $this->queueName,
+                'error' => $e->getMessage(),
+                'message_body' => $msg->getBody()
+            ]);
+
+            // 拒绝消息并发送到死信队列
+            //$msg->nack(false, false);
+            $this->sendToDeadLetterQueue($data, $e);
+            $msg->ack(); // 确认原消息以避免重复
+            $this->error("已发送到死信队列");
+            $this->processedCount++;
+        }
+    }
+
+    private function createJob(string $messageId, array $data, int $retryCount): BaseRabbitMQJob
+    {
+        // 根据队列名称创建对应的 Job 实例
+        switch ($this->queueName) {
+            case 'ai_translate':
+                return new ProcessAITranslateJob(
+                    $this->queueName,
+                    $messageId,
+                    $data,
+                    $retryCount,
+                );
+                // 可以添加更多队列类型
+            default:
+                throw new \Exception("未知的队列类型: {$this->queueName}");
+        }
+    }
+
+    private function handleJobException(AMQPMessage $msg, array $data, int $retryCount, \Exception $e)
+    {
+        $maxRetries = $this->queueConfig['retry_times'];
+
+        if ($retryCount < $maxRetries - 1) {
+            // 还有重试机会,重新入队
+            $this->requeueMessage($msg, $data, $retryCount + 1);
+            $this->info("消息重新入队,重试次数: " . ($retryCount + 1) . "/{$maxRetries}");
+        } else {
+            // 超过重试次数,发送到死信队列
+            $this->sendToDeadLetterQueue($data, $e);
+            $msg->ack(); // 确认原消息以避免重复
+            $this->error("消息超过最大重试次数,已发送到死信队列 ");
+            Log::error("消息超过最大重试次数,已发送到死信队列 message_id=" . $msg->get('message_id'));
+        }
+
+        $this->processedCount++;
+    }
+
+    private function requeueMessage(AMQPMessage $msg, array $data, int $newRetryCount)
+    {
+        // 添加重试计数到消息头
+        // 使用 AMQPTable 包装头部数据
+        $headers = new AMQPTable([
+            'retry_count' => $newRetryCount,
+            'original_queue' => $this->queueName,
+            'retry_timestamp' => time()
+        ]);
+
+        $newMsg = new AMQPMessage(
+            json_encode($data, JSON_UNESCAPED_UNICODE),
+            [
+                'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
+                'timestamp' => time(),
+                'message_id' => $msg->get('message_id'),
+                'application_headers' => $headers,
+                "content_type" => 'application/json; charset=utf-8'
+            ]
+        );
+
+        // 发布到同一队列
+        $this->channel->basic_publish($newMsg, '', $this->queueName);
+
+        // 确认原消息
+        $msg->ack();
+    }
+
+    private function sendToDeadLetterQueue(array $data, \Exception $e)
+    {
+        $dlqName = $this->queueConfig['dead_letter_queue'];
+
+        $dlqData = [
+            'original_message' => $data,
+            'failure_reason' => $e->getMessage(),
+            'failed_at' => date('Y-m-d H:i:s'),
+            'queue' => $this->queueName,
+            'max_retries' => $this->queueConfig['retry_times']
+        ];
+
+        $dlqMsg = new AMQPMessage(
+            json_encode($dlqData),
+            ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]
+        );
+
+        $this->channel->basic_publish($dlqMsg, '', $dlqName);
+
+        Log::error("消息发送到死信队列", [
+            'original_queue' => $this->queueName,
+            'dead_letter_queue' => $dlqName,
+            'error' => $e->getMessage()
+        ]);
+    }
+
+    /**
+     * 处理系统信号
+     *
+     * @param int $signal 信号类型
+     * @param int|false $previousExitCode 上一个退出码
+     * @return int|false 返回退出码或 false
+     */
+    public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false
+    {
+        $this->info("接收到退出信号,正在优雅关闭...");
+        $this->shouldStop = true;
+
+        if ($this->job) {
+            $this->job->stop();
+        }
+
+        if ($this->channel && $this->channel->is_consuming()) {
+            //$this->channel->basic_cancel_on_shutdown(true);
+            $this->channel->basic_cancel('');
+        }
+
+        // 返回 false 表示信号已处理,不需要进一步传播
+        return false;
+    }
+
+    private function cleanup()
+    {
+        try {
+            if ($this->channel) {
+                $this->channel->close();
+            }
+            if ($this->connection) {
+                $this->connection->close();
+            }
+
+            $this->info("连接已关闭,处理了 {$this->processedCount} 条消息");
+        } catch (\Exception $e) {
+            $this->error("清理资源时出错: " . $e->getMessage());
+        }
+    }
+}

+ 61 - 0
api-v12/app/Console/Commands/RemoveTermCache.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\DhammaTerm;
+use App\Models\Channel;
+use Illuminate\Support\Facades\Cache;
+
+class RemoveTermCache extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'remove:term.cache {word?}';
+
+    /**
+     * 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;
+        }
+        $word = $this->argument('word');
+        $channels = Channel::select('uid')->get();
+        if(empty($word)){
+
+        }else{
+            foreach ($channels as $key => $channel) {
+                $key = "/term/{$channel}/{$word}";
+                if(Cache::has($key)){
+                    $this->info('has:'.$key);
+                    Cache::forget($key);
+                }
+            }
+        }
+        return 0;
+    }
+}

+ 80 - 0
api-v12/app/Console/Commands/StatisticsDict.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use Carbon\Carbon;
+use App\Models\UserOperationLog;
+
+class StatisticsDict extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'statistics:dict';
+
+    /**
+     * 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;
+        }
+        $file = "public/statistics/lookup-monthly.csv";
+        Storage::disk('local')->put($file, "");
+        #按月获取数据
+        $firstDay = UserOperationLog::where('op_type','dict_lookup')
+                            ->orderBy('created_at')
+                            ->select('created_at')
+                            ->first();
+        $firstDay = strtotime($firstDay->created_at);
+        $firstMonth = Carbon::create(date("Y-m",$firstDay));
+        $now = Carbon::now();
+        $current = $firstMonth;
+        $sumCount = 0;
+        while ($current <= $now) {
+            # code...
+            $start = Carbon::create($current)->startOfMonth();
+            $end = Carbon::create($current)->endOfMonth();
+            $date = $current->format('Y-m');
+            $count = UserOperationLog::where('op_type','dict_lookup')
+                              ->whereDate('created_at','>=',$start)
+                              ->whereDate('created_at','<=',$end)
+                              ->count();
+            $sumCount += $count;
+            $editor = UserOperationLog::where('op_type','dict_lookup')
+                              ->whereDate('created_at','>=',$start)
+                              ->whereDate('created_at','<=',$end)
+                              ->groupBy('user_id')
+                              ->select('user_id')->get();
+            $info = $date.','.$count.','.$sumCount.','.count($editor);
+            $this->info($info);
+            Storage::disk('local')->append($file, $info);
+            $current->addMonth(1);
+        }
+        return 0;
+    }
+}

+ 77 - 0
api-v12/app/Console/Commands/StatisticsExp.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use Carbon\Carbon;
+use App\Models\UserOperationDaily;
+
+class StatisticsExp extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'statistics:exp';
+
+    /**
+     * 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;
+        }
+        $file = "public/statistics/exp-monthly.csv";
+        Storage::disk('local')->put($file, "");
+        #按月获取数据
+        $firstDay = UserOperationDaily::select('created_at')
+                            ->orderBy('created_at')
+                            ->first();
+        $firstDay = strtotime($firstDay->created_at);
+        $firstMonth = Carbon::create(date("Y-m",$firstDay));
+        $now = Carbon::now();
+        $current = $firstMonth;
+        $sumTime = 0;
+        while ($current <= $now) {
+            # code...
+            $start = Carbon::create($current)->startOfMonth();
+            $end = Carbon::create($current)->endOfMonth();
+            $date = $current->format('Y-m');
+            $time = UserOperationDaily::whereDate('created_at','>=',$start)
+                              ->whereDate('created_at','<=',$end)
+                              ->sum('duration')/1000;
+            $sumTime += $time;
+            $editor = UserOperationDaily::whereDate('created_at','>=',$start)
+                              ->whereDate('created_at','<=',$end)
+                              ->groupBy('user_id')
+                              ->select('user_id')->get();
+            $info = $date.','.(int)($time/3600).','.(int)($sumTime/3600).','.count($editor);
+            $this->info($info);
+            Storage::disk('local')->append($file, $info);
+            $current->addMonth(1);
+        }
+        return 0;
+    }
+}

+ 90 - 0
api-v12/app/Console/Commands/StatisticsNissaya.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\Channel;
+use App\Models\Sentence;
+use Illuminate\Support\Facades\Storage;
+use Carbon\Carbon;
+
+class StatisticsNissaya extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan statistics:nissaya
+     * @var string
+     */
+    protected $signature = 'statistics:nissaya';
+
+    /**
+     * 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));
+        $file = "public/statistics/nissaya-monthly.csv";
+
+        Storage::disk('local')->put($file, "");
+        #按月获取数据
+        $firstDay = Sentence::whereIn('channel_uid',$nissaya_channels)
+                            ->orderBy('created_at')
+                            ->select('created_at')
+                            ->first();
+        $firstDay = strtotime($firstDay->created_at);
+        $firstMonth = Carbon::create(date("Y-m",$firstDay));
+        $now = Carbon::now();
+        $current = $firstMonth;
+        $sumStrlen = 0;
+        $sumCount = 0;
+        while ($current <= $now) {
+            # code...
+            $start = Carbon::create($current)->startOfMonth();
+            $end = Carbon::create($current)->endOfMonth();
+            $date = $current->format('Y-m');
+            $table = Sentence::whereIn('channel_uid',$nissaya_channels)
+                              ->whereDate('created_at','>=',$start)
+                              ->whereDate('created_at','<=',$end);
+            $strlen =  $table->sum('strlen');
+            $sumStrlen += $strlen;
+            $count = $table->count();
+            $sumCount += $count;
+            $editor = Sentence::whereIn('channel_uid',$nissaya_channels)
+                              ->whereDate('created_at','>=',$start)
+                              ->whereDate('created_at','<=',$end)
+                              ->groupBy('editor_uid')
+                              ->select('editor_uid')->get();
+            $info = "{$date},{$strlen},{$sumStrlen},{$count},{$sumCount},".count($editor);
+            $this->info($info);
+            Storage::disk('local')->append($file, $info);
+            $current->addMonth(1);
+        }
+        $this->info('path='.$file);
+        return 0;
+    }
+}

+ 144 - 0
api-v12/app/Console/Commands/StatisticsNissayaCover.php

@@ -0,0 +1,144 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\Channel;
+use App\Models\Sentence;
+use App\Models\PaliSentence;
+use App\Tools\RedisClusters;
+
+class StatisticsNissayaCover extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan statistics:nissaya.cover
+     * @var string
+     */
+    protected $signature = 'statistics:nissaya.cover';
+    protected $types = [
+        '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,],
+    ];
+    /**
+     * 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));
+        $output = [];
+        foreach ($this->types as $type => $books) {
+            # code...
+            $pali = PaliSentence::whereIn('book',$books)->sum('length');
+            $nissayaSentences = Sentence::whereIn('channel_uid',$nissaya_channels)
+                                ->whereIn('book_id',$books)
+                                ->groupBy(['book_id','paragraph','word_start','word_end'])
+                                ->select(['book_id','paragraph','word_start','word_end'])
+                                ->get();
+            $sentences = [];
+            $final = 0;
+            $this->info($type . count($nissayaSentences). " sentences");
+            if(count($nissayaSentences)>0){
+                $count = 0;
+                foreach ($nissayaSentences as  $value) {
+                    $sentences[] = [
+                        $value->book_id,
+                        $value->paragraph,
+                        $value->word_start,
+                        $value->word_end,
+                    ];
+                    if($count % 100 === 0 ){
+                        $final += PaliSentence::whereIns(['book','paragraph','word_begin','word_end'],$sentences)
+                                        ->sum('length');
+                        $sentences = [];
+                        $percent = intval($count * 100 / count($nissayaSentences));
+                        $this->info("[{$percent}] {$final}");
+                    }
+                    $count++;
+                }
+
+            }
+
+            $this->info($type . '=' . $pali . '=' . $final);
+            $output[] = ['type'=>$type,'total'=>$pali,'final'=>$final];
+
+        }
+        RedisClusters::put('/statistics/nissaya/cover',$output,48*3600);
+        return 0;
+    }
+}

+ 77 - 0
api-v12/app/Console/Commands/StatisticsWbw.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\Wbw;
+use Illuminate\Support\Facades\Storage;
+use Carbon\Carbon;
+
+class StatisticsWbw extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'statistics:wbw';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '统计 wbw 每月建立数量';
+
+    /**
+     * 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;
+        }
+        $file = "public/statistics/wbw-monthly.csv";
+        Storage::disk('local')->put($file, "");
+        #按月获取数据
+        $firstDay = Wbw::select('created_at')
+                        ->orderBy('created_at')
+                        ->first();
+        $firstDay = strtotime($firstDay->created_at);
+        $firstMonth = Carbon::create(date("Y-m",$firstDay));
+        $now = Carbon::now();
+        $current = $firstMonth;
+        $sumCount = 0;
+        while ($current <= $now) {
+            # code...
+            $start = Carbon::create($current)->startOfMonth();
+            $end = Carbon::create($current)->endOfMonth();
+            $date = $current->format('Y-m');
+            $count = Wbw::whereDate('created_at','>=',$start)
+                              ->whereDate('created_at','<=',$end)
+                              ->count();
+            $sumCount += $count;
+            $editor = Wbw::whereDate('created_at','>=',$start)
+                              ->whereDate('created_at','<=',$end)
+                              ->groupBy('editor_id')
+                              ->select('editor_id')->get();
+            $info = $date.','.$count.','.$sumCount.','.count($editor);
+            $this->info($info);
+            Storage::disk('local')->append($file, $info);
+            $current->addMonth(1);
+        }
+        return 0;
+    }
+}

+ 60 - 0
api-v12/app/Console/Commands/TestAI.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+
+class TestAI extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'test:ai';
+
+    /**
+     * 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()
+    {
+        $url = 'https://api.moonshot.cn/v1/chat/completions';
+        $param = [
+                "model" => "moonshot-v1-8k",
+                "messages" => [
+                    ["role" => "system","content" => "你是 Kimi,由 Moonshot AI 提供的人工智能助手,你更擅长中文和英文的对话。你会为用户提供安全,有帮助,准确的回答。同时,你会拒绝一切涉及恐怖主义,种族歧视,黄色暴力等问题的回答。Moonshot AI 为专有名词,不可翻译成其他语言。"],
+                    ["role" => "user","content" => "你好,我叫李雷,1+1等于多少?"],
+                ],
+                "temperature" => 0.3,
+        ];
+        $response = Http::withToken('sk-kwjHIMh3PoWwUwQyKdT3KHvNe8Es19SUiujGrxtH09uDQCui')
+                        ->post($url,$param);
+        if($response->failed()){
+            $this->error('http request error'.$response->json('message'));
+        }else{
+            $this->info(json_encode($response->json()));
+        }
+        return 0;
+    }
+}

+ 55 - 0
api-v12/app/Console/Commands/TestAiTask.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\TaskAssignee;
+use App\Models\AiModel;
+
+class TestAiTask extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan test:ai.task c77af42f-ffb5-48ae-af71-4c32e1c30dab
+     * php artisan test:ai.task f42fa690-c590-400f-9de9-fbc81e838a5a
+     * @var string
+     */
+    protected $signature = 'test:ai.task {id} {--test}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'test ai task';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $taskId = $this->argument('id');
+        $taskAssignee = TaskAssignee::where('task_id', $taskId)
+            ->select('assignee_id')->get();
+        $aiAssistant = AiModel::whereIn('uid', $taskAssignee)->first();
+        if ($aiAssistant) {
+            $count = \App\Jobs\ProcessAITranslateJob::publish($taskId, $aiAssistant->uid);
+            $this->info('publish total:' . $count);
+        } else {
+            $this->error('no ai assistant');
+        }
+        return 0;
+    }
+}

+ 69 - 0
api-v12/app/Console/Commands/TestCaseMan.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Tools\CaseMan;
+use App\Models\UserDict;
+
+
+class TestCaseMan extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'test:case {word}';
+
+    /**
+     * 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;
+        }
+		$caseMan = new CaseMan();
+        $case = $caseMan->Declension($this->argument('word'),'.n:base.','.nt.',0.5);
+        print_r($case);
+        return 0;
+
+		$parents = $caseMan->WordToBase($this->argument('word'),1);
+			# code...
+
+		foreach ($parents as $base => $rows) {
+			# code...
+			if(count($rows)==0){
+				$this->error("base={$base}-(".count($rows).")");
+			}else{
+				$this->warn("base={$base}-(".count($rows).")");
+			}
+
+			foreach ($rows as $value) {
+				# code...
+				$this->info($value['word'].'-'.$value['type'].'-'.$value['grammar'].'-'.$base);
+			}
+		}
+        return 0;
+    }
+}

+ 53 - 0
api-v12/app/Console/Commands/TestJsonToXml.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Tools\Tools;
+
+class TestJsonToXml extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'test:json.to.xml';
+
+    /**
+     * 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;
+        }
+        $array = [
+            'pali'=>['status'=>'7','value'=>'bārāṇasiyaṃ'],
+            'real'=>['status'=>'7','value'=>'bārāṇasiyaṃ'],
+            'id'=>'p171-2475-10'
+        ];
+        $xml = Tools::JsonToXml($array);
+        $this->info($xml);
+        return 0;
+    }
+}

+ 83 - 0
api-v12/app/Console/Commands/TestMarkdownToTpl.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Controllers\ArticleController;
+use Illuminate\Support\Facades\Log;
+
+class TestMarkdownToTpl extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'test:markdown.tpl {item?}';
+
+    /**
+     * 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;
+        }
+        Log::info('md render start item='.$this->argument('item'));
+        $data = array();
+        $data['basic'] = <<<md
+        # 去除烦恼的五种方法(一种分类)
+
+        1 为了自己的利益而从别人处听法;
+        2 为了自己的利益而开示自己听闻过的法;
+        3 念诵听闻过、学习过的法;
+        4 一次又一次地用心思维听闻过、学习过的法;
+        5 在心中忆念自己适合的禅修业处,如十遍、十不净等。
+
+        (无碍解道,义注,1,63)
+        md;
+
+        $data['tpl'] = <<<md
+        为了自己的利益而从别人处听法;
+
+        {{168-916-2-9}}
+        md;
+
+        $article = new ArticleController;
+
+        foreach ($data as $key => $value) {
+            $_item = $this->argument('item');
+            if(!empty($_item) && $key !==$_item){
+                continue;
+            }
+            $tpl = $article->toTpl($value,
+                        'eb9e3f7f-b942-4ca4-bd6f-b7876b59a523',
+                        [
+                            'user_uid'=>'ba5463f3-72d1-4410-858e-eadd10884713',
+                            'user_id'=>4,
+                        ]
+                    );
+            var_dump($tpl);
+        }
+        return 0;
+    }
+}

+ 200 - 0
api-v12/app/Console/Commands/TestMdRender.php

@@ -0,0 +1,200 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\MdRender;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Log;
+use App\Tools\Markdown;
+
+class TestMdRender extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan test:md.render term --format=unity --driver=str
+     * @var string
+     */
+    protected $signature = 'test:md.render {item?} {--format=html} {--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()
+    {
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+
+        $content = <<<md
+        # 测试
+        ## 测试
+
+        {{note|text=这是一个普通信息提示|type=info}}
+
+        下面是一个警告信息:
+        {{warning|message=请注意这个重要提示|title=重要}}
+
+        你也可以使用位置参数:
+        {{note|
+        成功完成操作|
+        success}}
+
+        支持嵌套模板:
+        {{info|
+        content=外层信息 {{note|内嵌提示|warning}} 继续外层|
+        title=嵌套示例}}
+
+        **粗体文本** 和 *斜体文本* 也被支持。
+        md;
+        $parser = new \App\Services\Template\TemplateService(false);
+        $render = $parser->parseAndRender($content, 'json');
+        var_dump($render);
+        return 0;
+
+        Log::info('md render start item=' . $this->argument('item'));
+        $data = array();
+        $data['bold'] = <<<md
+        **三十位** 经在[中间]六处为**[licchavi]**,在极果为**慧解脱**
+        md;
+
+        $data['sentence'] = <<<md
+        {{168-916-2-9}}
+        md;
+
+        $data['link'] = <<<md
+        aa `[link](wikipali.org/aa.php?view=b&c=d)` bb
+        md;
+
+        $data['term'] = <<<md
+        ## term
+        [[bhagavantu]]
+        md;
+        $data['noteMulti'] = <<<md
+        ## heading
+
+        [点击](http://127.0.0.1:3000/my/article/para/168-876?mode=edit&channel=00ae2c48-c204-4082-ae79-79ba2740d506&book=168&par=876)
+
+        ----
+
+        dfef
+
+        ```
+        bla **content**
+        {{99-556-8-12}}
+        bla **content**
+        ```
+        md;
+
+        $data['note'] = '`bla **bold** _em_ bla`';
+        $data['noteTpl'] = <<<md
+        {{note|trigger=kacayana|text=bla **bold** _em_ bla}}
+        md;
+
+        $data['noteTpl2'] = <<<md
+        {{note|trigger=kacayana|text={{99-556-8-12}}}}
+        md;
+
+        $data['trigger'] = <<<md
+        ## heading
+        ddd
+        - title
+          content-1
+        - title-2
+
+          content-2
+
+        aaa bbb
+        md;
+        $data['exercise'] = <<<md
+        {{168-916-10-37}}
+        {{exercise|1|((168-916-10-37))}}
+        {{exercise|
+        id=1|
+        content={{168-916-10-37}}
+        }}
+        {{exercise|
+        id=2|
+        content=# ddd}}
+        md;
+
+        $data['article'] = <<<md
+        {{article|
+        type=article|
+        id=27ade9ad-2d0c-4f66-b857-e9335252cc08|
+        title=第一章 戒律概说(Vinaya)|
+        style=modal}}
+        md;
+
+        $data['footnote'] = <<<md
+        # title
+        content `note content` `note2 content`
+        md;
+
+        $data['paragraph'] = <<<md
+        # title
+        content
+
+        {{168-916-10-37}}
+        {{168-916-10-37}}
+
+        the end
+        md;
+
+        $data['img'] = <<<md
+        # title
+        content
+
+        ![aaa](/images/aaa.jpg)
+
+        the end
+        md;
+
+        $data['empty'] = '';
+
+        Markdown::driver($this->option('driver'));
+
+        $format = $this->option('format');
+        if (empty($format)) {
+            $formats = ['react', 'unity', 'text', 'tex', 'html', 'simple'];
+        } else {
+            $formats = [$format];
+        }
+        foreach ($formats as $format) {
+            $this->info("format:{$format}");
+            foreach ($data as $key => $value) {
+                $_item = $this->argument('item');
+                if (!empty($_item) && $key !== $_item) {
+                    continue;
+                }
+                $mdRender = new MdRender([
+                    'format' => $format,
+                    'footnote' => true,
+                    'paragraph' => true,
+                ]);
+                $output = $mdRender->convert($value, ['00ae2c48-c204-4082-ae79-79ba2740d506']);
+                echo $output;
+            }
+        }
+        return 0;
+    }
+}

+ 68 - 0
api-v12/app/Console/Commands/TestMq.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Str;
+
+use App\Http\Api\Mq;
+use App\Models\Discussion;
+use App\Http\Resources\DiscussionResource;
+use App\Models\SentPr;
+use App\Http\Resources\SentPrResource;
+use App\Services\RabbitMQService;
+
+class TestMq extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan test:mq
+     * @var string
+     */
+    protected $signature = 'test:mq {--discussion=} {--pr=}';
+    protected $publish;
+    /**
+     * 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;
+        }
+        $publish = app(RabbitMQService::class);
+        $this->publish = $publish;
+        $this->publish->publishMessage('ai_translate', ['text' => 'hello']);
+
+        Mq::publish('hello', ['hello world']);
+        $discussion = $this->option('discussion');
+        if ($discussion && Str::isUuid($discussion)) {
+            Mq::publish('discussion', new DiscussionResource(Discussion::find($discussion)));
+        }
+
+        $pr = $this->option('pr');
+        if ($pr && Str::isUuid($pr)) {
+            Mq::publish('suggestion', new SentPrResource(SentPr::where('uid', $pr)->first()));
+        }
+
+        return 0;
+    }
+}

+ 46 - 0
api-v12/app/Console/Commands/TestMqExit.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\Mq;
+
+class TestMqExit extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan test:mq.exit
+     * @var string
+     */
+    protected $signature = 'test:mq.exit';
+
+    /**
+     * 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()
+    {
+        for ($i = 0; $i < 10; $i++) {
+            Mq::publish('ai_translate', ['hello world']);
+        }
+        return 0;
+    }
+}

+ 131 - 0
api-v12/app/Console/Commands/TestMqWorker.php

@@ -0,0 +1,131 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use PhpAmqpLib\Connection\AMQPStreamConnection;
+use PhpAmqpLib\Exchange\AMQPExchangeType;
+
+class TestMqWorker extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan test:mq.worker
+     * @var string
+     */
+    protected $signature = 'test:mq.worker';
+
+    /**
+     * 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;
+        }
+        $exchange = 'router';
+        $queue = 'hello';
+        $consumerTag = 'consumer';
+        $connection = new AMQPStreamConnection(config("queue.connections.rabbitmq.host"),
+                                            config("queue.connections.rabbitmq.port"),
+                                            config("queue.connections.rabbitmq.user"),
+                                            config("queue.connections.rabbitmq.password"),
+                                            config("queue.connections.rabbitmq.virtual_host"));
+        $channel = $connection->channel();
+
+        /*
+            The following code is the same both in the consumer and the producer.
+            In this way we are sure we always have a queue to consume from and an
+                exchange where to publish messages.
+        */
+
+        /*
+            name: $queue
+            passive: false
+            durable: true // the queue will survive server restarts
+            exclusive: false // the queue can be accessed in other channels
+            auto_delete: false //the queue won't be deleted once the channel is closed.
+        */
+        $channel->queue_declare($queue, false, true, false, false);
+
+        /*
+            name: $exchange
+            type: direct
+            passive: false
+            durable: true // the exchange will survive server restarts
+            auto_delete: false //the exchange won't be deleted once the channel is closed.
+        */
+
+        $channel->exchange_declare($exchange, AMQPExchangeType::DIRECT, false, true, false);
+
+        $channel->queue_bind($queue, $exchange);
+
+        /**
+         * @param \PhpAmqpLib\Message\AMQPMessage $message
+         */
+        $process_message = function ($message)
+        {
+            echo "\n--------\n";
+            echo $message->body;
+            echo "\n--------\n";
+
+            $message->ack();
+
+            // Send a message with the string "quit" to cancel the consumer.
+            if ($message->body === 'quit') {
+                $message->getChannel()->basic_cancel($message->getConsumerTag());
+            }
+        };
+
+        /*
+            queue: Queue from where to get the messages
+            consumer_tag: Consumer identifier
+            no_local: Don't receive messages published by this consumer.
+            no_ack: If set to true, automatic acknowledgement mode will be used by this consumer. See https://www.rabbitmq.com/confirms.html for details.
+            exclusive: Request exclusive consumer access, meaning only this consumer can access the queue
+            nowait:
+            callback: A PHP Callback
+        */
+
+        $channel->basic_consume($queue, $consumerTag, false, false, false, false, $process_message);
+
+        /**
+         * @param \PhpAmqpLib\Channel\AMQPChannel $channel
+         * @param \PhpAmqpLib\Connection\AbstractConnection $connection
+         */
+        $shutdown = function ($channel, $connection)
+        {
+            $channel->close();
+            $connection->close();
+        };
+
+        register_shutdown_function($shutdown, $channel, $connection);
+
+        // Loop as long as the channel has callbacks registered
+        while ($channel->is_consuming()) {
+            $channel->wait(null, true);
+            // do something else
+            usleep(300000);
+        }
+        return 0;
+    }
+}

+ 126 - 0
api-v12/app/Console/Commands/TestProjectCopyTask.php

@@ -0,0 +1,126 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Str;
+
+class TestProjectCopyTask extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan test:project.copy.task project-50 dd9bcba8-ad3f-4082-9b52-4f5f8acdbd5f visuddhinanda
+     * @var string
+     */
+    protected $signature = 'test:project.copy.task {project} {task} {studio} {--token=}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '建立project 并复制task';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $appUrl = config('app.url');
+        $projectTitle = $this->argument('project');
+        $taskId = $this->argument('task');
+        $studioName = $this->argument('studio');
+        $token = $this->option('token');
+
+        // 如果 role 选项未提供(为空),提示用户输入
+        if (empty($token)) {
+            $token = $this->ask('Please enter the user token:');
+        }
+
+        $taskCount = $this->ask('Please enter the task count:');
+        $url = $appUrl . '/api/v2/project-tree';
+        $this->info('create project ' . $url);
+        $projects = array();
+        $rootId = Str::uuid();
+        $projects[] = [
+            'id' => $rootId,
+            'title' => $projectTitle,
+            'type' => "instance",
+            'parent_id' => '',
+            'weight' => 0,
+            'res_id' => $rootId,
+        ];
+        for ($i = 0; $i < $taskCount; $i++) {
+            $uid = Str::uuid();
+            $projects[] = [
+                'id' => $uid,
+                'title' => "{$projectTitle}_{$i}",
+                'type' => "instance",
+                'parent_id' => $rootId,
+                'weight' => 0,
+                'res_id' => $uid,
+            ];
+        }
+        $response = Http::withToken($token)
+            ->post($url, [
+                'studio_name' => $studioName,
+                'data' => $projects,
+            ]);
+        if ($response->failed()) {
+            $this->error('project create fail' . $response->json('message'));
+            Log::error('project create fail', ['data' => $response->body()]);
+            return 1;
+        }
+
+        $projectsData = $response->json()['data']['rows'];
+        $this->info('project :' . count($projectsData));
+        //获取task
+        $response = Http::withToken($token)
+            ->get($appUrl . '/api/v2/task/' . $taskId);
+        if ($response->failed()) {
+            $this->error('task read fail' . $response->json('message'));
+            Log::error('task read fail', ['data' => $response->body()]);
+            return 1;
+        }
+
+        //建立task
+        $task = $response->json()['data'];
+        $taskTitle = $task['title'];
+        $this->info('task title:' . $task['title']);
+        $tasks = array();
+        foreach ($projectsData as $key => $project) {
+            if ($project['isLeaf']) {
+                $task['title'] = "{$taskTitle}_{$key}";
+                $tasks[] = [
+                    'project_id' => $project['id'],
+                    'tasks' => [$task]
+                ];
+            }
+        }
+
+        $response = Http::withToken($token)
+            ->post($appUrl . '/api/v2/task-group', [
+                'data' => $tasks,
+            ]);
+        if ($response->failed()) {
+            $this->error('task create fail' . $response->json('message'));
+            Log::error('task create fail', ['data' => $response->body()]);
+            return 1;
+        }
+        return 0;
+    }
+}

+ 48 - 0
api-v12/app/Console/Commands/TestSchedule.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+
+class TestSchedule extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'test:schedule';
+
+    /**
+     * 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;
+        }
+        Log::info('schedule test start');
+        $this->info('schedule test start');
+        return 0;
+    }
+}

+ 77 - 0
api-v12/app/Console/Commands/TestSearchPali.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Tools\PaliSearch;
+
+class TestSearchPali extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan test:search.pali
+     * @var string
+     */
+    protected $signature = 'test:search.pali {word?}';
+
+    /**
+     * 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()
+    {
+        $word = $this->argument('word');
+        if(empty($word)){
+            $word = 'citta';
+        }
+        $words = str_replace('_',' ',$word);
+        $words = explode(',',$words);
+        $this->info("searching word={$word} limit=10,offset=0");
+        $result = PaliSearch::search($words,[],'case',0,10);
+        if($result){
+            $this->info("word={$word} total=".$result['total']);
+        }else{
+            $this->error("word={$word} search fail");
+        }
+
+        $rpc_result = PaliSearch::book_list($words,
+                                            [],
+                                            'case');
+        $this->info('book list count='.count($rpc_result['rows']));
+
+        $this->info("searching word={$word} limit=10,offset=10");
+        $result = PaliSearch::search($words,[],'case',10,10);
+        if($result){
+            $this->info("word={$word} total=".$result['total']);
+        }else{
+            $this->error("word={$word} search fail");
+        }
+        $this->info("searching word={$word} book=267");
+        $result = PaliSearch::search($words,[267],'case',0,3);
+        if($result){
+            $this->info("word={$word} book=267 total=".$result['total']);
+        }else{
+            $this->error("word={$word} book=267 search fail");
+        }
+
+        return 0;
+    }
+}

+ 102 - 0
api-v12/app/Console/Commands/TestTex.php

@@ -0,0 +1,102 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use App\Tools\Export;
+
+class TestTex extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'test:tex';
+
+    /**
+     * 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;
+        }
+        $tex = array();
+        $content = <<<'EOF'
+% 导言区
+\documentclass[a4paper, 12pt, fontset=ubuntu]{article} % book, report, letter
+\usepackage{ctex} % Use chinese package
+
+\title{\heiti 一级标题}
+\author{\kaishu 半闲}
+\date{\today}
+
+% 正文区
+
+\begin{document}
+    \maketitle % 头部信息在正文显示
+    \newpage
+    \tableofcontents % 显示索引列
+
+    \include{section-1.tex}
+    \include{section-2.tex}
+
+\end{document}
+
+EOF;
+$tex[] = ['name'=>'main.tex','content'=>$content];
+$content = <<<'EOF'
+\section{三十位经}
+
+住在王舍城的竹林园。
+那时,三十位波婆城的比丘全是住林野者、全是常乞食者、全是穿粪扫衣者、全是但三衣者、全是尚有结缚者,他们去见世尊。
+\subsubsection{子章节1.1 标题}
+子章节1-1 正文
+\subsection{子章节1.2 标题}
+子章节1-2 正文
+EOF;
+$tex[] = ['name'=>'section-1.tex','content'=>$content];
+
+$content = <<<'EOF'
+\section{章节2 标题}
+章节2 正文
+\subsection{子章节2.1 标题}
+子章节2-1 正文
+\subsection{子章节2.2 标题}
+子章节2-2 正文
+EOF;
+
+$tex[] = ['name'=>'section-2.tex','content'=>$content];
+
+        $data = Export::ToPdf($tex);
+        if($data['ok']){
+            $filename = "export/test.pdf";
+            $this->info($data['content-type']);
+            Storage::disk('local')->put($filename, $data['data']);
+        }else{
+            $this->error($data['code'].'-'.$data['message']);
+        }
+        return 0;
+    }
+}

+ 76 - 0
api-v12/app/Console/Commands/TestWorkerStartProject.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Http;
+
+class TestWorkerStartProject extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan test:worker.start.project 0c3d2f69-1098-428b-95db-f1183667c799
+     * @var string
+     */
+    protected $signature = 'test:worker.start.project {project} {--token=}';
+
+    /**
+     * 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()
+    {
+        $appUrl = config('app.url');
+        $projectId = $this->argument('project');
+        $token = $this->option('token');
+        // 如果 role 选项未提供(为空),提示用户输入
+        if (empty($token)) {
+            $token = $this->ask('Please enter the user token:');
+        }
+
+        $status = $this->choice(
+            'Which framework do you prefer?',
+            ['published', 'restarted', 'stop'],
+            0 // 默认选择 Laravel(索引 0)
+        );
+
+        $response = Http::withToken($token)
+            ->get($appUrl . "/api/v2/task?view=project&project_id={$projectId}&status=all&order=order&dir=asc");
+        if ($response->failed()) {
+            $this->error('task read fail' . $response->json('message'));
+            Log::error('task read fail', ['data' => $response->body()]);
+            return 1;
+        }
+        $tasks = $response->json()['data']['rows'];
+        foreach ($tasks as $key => $task) {
+            $this->info("[{$key}]task " . $task['title'] . ' status ' . $task['status']);
+            $response = Http::withToken($token)
+                ->patch($appUrl . "/api/v2/task-status/" . $task['id'], ['status' => $status]);
+            if ($response->failed()) {
+                $this->error('task status fail' . $response->json('message'));
+                Log::error('task status fail', ['data' => $response->body()]);
+            }
+            $this->info("[{$key}]task status changed {$status}");
+        }
+        return 0;
+    }
+}

+ 62 - 0
api-v12/app/Console/Commands/UpdateRelationTo.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\Relation;
+
+class UpdateRelationTo extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'update:relation.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;
+        }
+        $count=0;
+        $all=0;
+        foreach (Relation::select(['id','to'])->cursor() as $relation) {
+            $all++;
+            if(!empty($relation->to)){
+                $old = json_decode($relation->to,true);
+                if(count(array_filter(array_keys($old),'is_string'))===0){
+                    //索引数组,需要转换
+                    $new = ['case'=>$old];
+                    Relation::where('id',$relation->id)->update(['to'=>json_encode($new)]);
+                    $count++;
+                }
+            }
+        }
+        $this->info("{$count} of {$all}");
+
+        return 0;
+    }
+}

+ 89 - 0
api-v12/app/Console/Commands/UpdateSentenceUnique.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\Sentence;
+use App\Models\SentHistory;
+use App\Models\Discussion;
+
+use Illuminate\Support\Facades\DB;
+
+class UpdateSentenceUnique extends Command
+{
+    /**
+     * 将channel+book+paragraph+start+end重复的数据筛查,合并
+     * 与此句相关的资源也要合并,包括,pr,history,discussion
+     * 多的句子软删除
+     * php artisan update:sentence.unique
+     * @var string
+     */
+    protected $signature = 'update:sentence.unique';
+
+    /**
+     * 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()
+    {
+        $queryCount = "SELECT count(*) from (SELECT * from (SELECT book_id ,paragraph ,word_start ,word_end ,channel_uid , count(*) as co from sentences s where ver = 2  group by book_id ,paragraph ,word_start ,word_end ,channel_uid) T where co>1) TT ";
+        $total = DB::select($queryCount);
+        $querySame = "SELECT * from (SELECT book_id ,paragraph ,word_start ,word_end ,channel_uid , count(*) as co from sentences s where ver = 2  group by book_id ,paragraph ,word_start ,word_end ,channel_uid) T where co>1";
+        $query = DB::select($querySame);
+
+        $count = 0;
+        foreach ($query as $key => $value) {
+            $count++;
+            $same = Sentence::where('book_id',$value->book_id)
+                            ->where('paragraph',$value->paragraph)
+                            ->where('word_start',$value->word_start)
+                            ->where('word_end',$value->word_end)
+                            ->where('channel_uid',$value->channel_uid)
+                            ->orderBy('updated_at','desc')
+                            ->get();
+            $per = (int)($count*100 / $total[0]->count);
+            $this->info("[{$per}]-{$count} ".$same[0]->updated_at.' '.$same[1]->updated_at.' '.count($same));
+
+            for ($i=1; $i < count($same); $i++) {
+                //将旧数据的历史记录 重新定位到新数据
+                $history = SentHistory::where('sent_uid',$same[$i]->uid)
+                                      ->update(['sent_uid'=>$same[0]->uid]);
+                //将旧数据的discussion 重新定位到新数据
+                $discussion = Discussion::where('res_id',$same[$i]->uid)
+                                        ->update(['res_id'=>$same[0]->uid]);
+                $this->info("{$history}-$discussion");
+                //将旧数据的 pr 重新定位到新数据
+                //删除旧数据
+                $same[$i]->delete();
+                if($same[$i]->trashed()){
+                    $this->info('软删除成功!');
+                }else{
+                    $this->error('软删除失败!');
+                }
+            }
+
+            if($count >= 1){
+                break;
+            }
+        }
+        return 0;
+    }
+}

+ 58 - 0
api-v12/app/Console/Commands/UpdateSentenceVer.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\Sentence;
+use Illuminate\Support\Str;
+
+class UpdateSentenceVer extends Command
+{
+    /**
+     * 将无channel_uid的旧版句子数据的ver修改为1.
+     * php artisan update:sentence.ver
+     * @var string
+     */
+    protected $signature = 'update:sentence.ver';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '将无channel_uid的旧版句子数据的ver修改为1';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        $count = 0;
+        $total = Sentence::whereNull('channel_uid')->orWhere('channel_uid','')->count();
+        foreach (Sentence::whereNull('channel_uid')->orWhere('channel_uid','')->cursor() as $key => $value) {
+            # code...
+            $value->ver = 1;
+            $value->channel_uid = Str::uuid();
+            $value->save();
+            $count++;
+            if($count % 1000 === 0){
+                $per = (int)($count*100 / $total);
+                $this->info("[{$per}%]-{$count}");
+            }
+        }
+        $this->info("all done [{$count}]");
+        return 0;
+    }
+}

+ 327 - 0
api-v12/app/Console/Commands/UpgradeAITranslation.php

@@ -0,0 +1,327 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Http;
+
+use App\Services\OpenAIService;
+use App\Services\AIModelService;
+use App\Services\SentenceService;
+use App\Services\SearchPaliDataService;
+use App\Http\Controllers\AuthController;
+
+use App\Models\PaliText;
+use App\Models\PaliSentence;
+use App\Models\Sentence;
+
+use App\Helpers\LlmResponseParser;
+
+use App\Http\Api\ChannelApi;
+use App\Tools\Tools;
+
+class UpgradeAITranslation extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan upgrade:ai.translation translation --book=141 --para=535
+     * @var string
+     */
+    protected $signature = 'upgrade:ai.translation {type} {--book=} {--para=} {--resume} {--model=} ';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Command description';
+    protected $sentenceService;
+    protected $modelService;
+    protected $openAIService;
+    protected $model;
+    protected $modelToken;
+    protected $workChannel;
+    protected $accessToken;
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        AIModelService $model,
+        SentenceService $sent,
+        OpenAIService $openAI
+    ) {
+        $this->modelService = $model;
+        $this->sentenceService = $sent;
+        $this->openAIService = $openAI;
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if ($this->option('model')) {
+            $this->model = $this->modelService->getModelById($this->option('model'));
+            $this->info("model:{$this->model['model']}");
+            $this->modelToken = AuthController::getUserToken($this->model['uid']);
+        }
+        $this->workChannel = ChannelApi::getById($this->ask('请输入结果channel'));
+
+        $books = [];
+        if ($this->option('book')) {
+            $books = [$this->option('book')];
+        } else {
+            $books = range(1, 217);
+        }
+        foreach ($books as $key => $book) {
+            $maxParagraph = PaliText::where('book', $book)->max('paragraph');
+            $paragraphs = range(1, $maxParagraph);
+            if ($this->option('para')) {
+                $paragraphs = [$this->option('para')];
+            }
+            foreach ($paragraphs as $key => $paragraph) {
+                $this->info($this->argument('type') . " {$book}-{$paragraph}");
+                $data = [];
+                switch ($this->argument('type')) {
+                    case 'translation':
+                        $data = $this->aiPaliTranslate($book, $paragraph);
+                        break;
+                    case 'nissaya':
+                        $data = $this->aiNissayaTranslate($book, $paragraph);
+                    case 'wbw':
+                        $data = $this->aiWBW($book, $paragraph);
+                    default:
+                        # code...
+                        break;
+                }
+                $this->save($data);
+            }
+        }
+        return 0;
+    }
+
+    private function getPaliContent($book, $para)
+    {
+        $sentenceService = app(SearchPaliDataService::class);
+        $sentences = PaliSentence::where('book', $book)
+            ->where('paragraph', $para)
+            ->orderBy('word_begin')
+            ->get();
+        if (!$sentences) {
+            return null;
+        }
+        $json = [];
+        foreach ($sentences as $key => $sentence) {
+            $content = $sentenceService->getSentenceText($book, $para, $sentence->word_begin, $sentence->word_end);
+            $id = "{$book}-{$para}-{$sentence->word_begin}-{$sentence->word_end}";
+            $json[] = ['id' => $id, 'content' => $content['markdown']];
+        }
+        return $json;
+    }
+
+    private function aiPaliTranslate($book, $para)
+    {
+        $prompt = <<<md
+        你是一个巴利语翻译助手。
+        pali 是巴利原文的一个段落,json格式, 每条记录是一个句子。包括id 和 content 两个字段
+        请翻译这个段落为简体中文。
+
+        翻译要求
+        1. 语言风格为现代汉语书面语,不要使用古汉语或者半文半白。
+        2. 译文严谨,完全贴合巴利原文,不要加入自己的理解
+        3. 巴利原文中的黑体字在译文中也使用黑体。其他标点符号跟随巴利原文,但应该替换为相应的汉字全角符号
+
+        输出格式jsonl
+        输出id 和 content 两个字段,
+        id 使用巴利原文句子的id ,
+        content 为中文译文
+
+        直接输出jsonl数据,无需解释
+
+
+    **输出范例**
+    {"id":"1-2-3-4","content":"译文"}
+    {"id":"2-3-4-5","content":"译文"}
+    md;
+
+        $pali = $this->getPaliContent($book, $para);
+        $originalText = "```json\n" . json_encode($pali, JSON_UNESCAPED_UNICODE) . "\n```";
+        Log::debug($originalText);
+        if (!$this->model) {
+            Log::error('model is invalid');
+            return [];
+        }
+        $startAt = time();
+        $response = $this->openAIService->setApiUrl($this->model['url'])
+            ->setModel($this->model['model'])
+            ->setApiKey($this->model['key'])
+            ->setSystemPrompt($prompt)
+            ->setTemperature(0.0)
+            ->setStream(false)
+            ->send("# pali\n\n{$originalText}\n\n");
+        $complete = time() - $startAt;
+        $translationText = $response['choices'][0]['message']['content'] ?? '[]';
+        Log::debug("complete in {$complete}s", $translationText);
+        $json = [];
+        if (is_string($translationText)) {
+            $json = LlmResponseParser::jsonl($translationText);
+        }
+        return $json;
+    }
+    private function aiWBW($book, $para)
+    {
+        $sysPrompt = <<<md
+        你是一个佛教翻译专家,精通巴利文和缅文,精通巴利文逐词解析
+        ## 翻译要求:
+        - 请将用户提供的巴利句子单词表中的每个巴利文单词翻译为中文
+        - 这些单词是一个完整的句子,请根据单词的上下文翻译
+        - original 里面的数据是巴利文单词
+        - 输入格式为 json 数组
+        - 输出jsonl格式
+
+        在原来的数据中添加下列输出字段
+        1. meaning:单词的中文意思,如果有两个可能的意思,两个意思之间用/符号分隔
+        5. confidence:你认为你给出的这个单词的信息的信心指数(准确程度) 数值1-100 如果觉得非常有把握100, 如果觉得把握不大,适当降低信心指数
+        6. note:如果你认为信心指数很低,这个是疑难单词,请在note字段写明原因,如果不是疑难单词,请不要填写note
+
+
+        **范例**:
+
+        {"id":1,"original":"bhikkhusanghassa","meaning":"比库僧团[的]","confidence":100}
+
+        直接输出jsonl, 无需其他内容
+        md;
+        $channelId = ChannelApi::getSysChannel('_System_Wbw_VRI_');
+        $sentences = Sentence::where('channel_uid', $channelId)
+            ->where('book_id', $book)
+            ->where('paragraph', $para)
+            ->get();
+        $result = [];
+        foreach ($sentences as $key => $sentence) {
+            $wbw = json_decode($sentence->content);
+            $tpl = [];
+            foreach ($wbw as $key => $word) {
+                if (
+                    !empty($word->real->value) &&
+                    $word->type->value !== '.ctl.'
+                ) {
+                    $tpl[] = [
+                        'id' => $word->sn[0],
+                        'original' => $word->real->value,
+                    ];
+                }
+            }
+
+            $tplText = json_encode($tpl, JSON_UNESCAPED_UNICODE);
+            Log::debug($tplText);
+            $startAt = time();
+            $response = $this->openAIService->setApiUrl($this->model['url'])
+                ->setModel($this->model['model'])
+                ->setApiKey($this->model['key'])
+                ->setSystemPrompt($sysPrompt)
+                ->setTemperature(0.7)
+                ->setStream(false)
+                ->send("```json\n{$tplText}\n```");
+            $complete = time() - $startAt;
+            $content = $response['choices'][0]['message']['content'] ?? '[]';
+            Log::debug("ai response in {$complete}s content=" . $content);
+
+            $json = LlmResponseParser::jsonl($content);
+
+            $id = "{$sentence->book_id}-{$sentence->paragraph}-{$sentence->word_start}-{$sentence->word_end}";
+            $result[] = [
+                'id' => $id,
+                'content' => json_encode($json, JSON_UNESCAPED_UNICODE),
+            ];
+        }
+        return $result;
+    }
+    private function aiNissayaTranslate($book, $para)
+    {
+        $sysPrompt = <<<md
+        你是一个佛教翻译专家,精通巴利文和缅文
+        ## 翻译要求:
+        - 请将nissaya单词表中的巴利文和缅文分别翻译为中文
+        - 输入格式为 巴利文:缅文
+        - 一行是一条记录,翻译的时候,请不要拆分一行中的巴利文单词或缅文单词,一行中出现多个单词的,一起翻译
+        - 输出csv格式内容,分隔符为"$",
+        - 字段如下:巴利文\$巴利文的中文译文\$缅文\$缅文的中文译文 #两个译文的语义相似度(%)
+
+        **范例**:
+
+        pana\$然而\$ဝါဒန္တရကား\$教义之说 #60%
+
+        直接输出csv, 无需其他内容
+        用```包裹的行为注释内容,也需要翻译和解释。放在最后面。如果没有```,无需处理
+        md;
+
+        $sentences = Sentence::nissaya()
+            ->language('my') // 过滤缅文
+            ->where('book_id', $book)
+            ->where('paragraph', $para)
+            ->orderBy('strlen')
+            ->get();
+        $result = [];
+        foreach ($sentences as $key => $sentence) {
+            $nissaya = [];
+            $rows = explode("\n", $sentence->content);
+            foreach ($rows as $key => $row) {
+                if (strpos('=', $row) >= 0) {
+                    $factors = explode("=", $row);
+                    $nissaya[] = Tools::MyToRm($factors[0]) . ':' . end($factors);
+                } else {
+                    $nissaya[] = $row;
+                }
+            }
+            $nissayaText = json_encode(implode("\n", $nissaya), JSON_UNESCAPED_UNICODE);
+            Log::debug($nissayaText);
+            $startAt = time();
+            $response = $this->openAIService->setApiUrl($this->model['url'])
+                ->setModel($this->model['model'])
+                ->setApiKey($this->model['key'])
+                ->setSystemPrompt($sysPrompt)
+                ->setTemperature(0.7)
+                ->setStream(false)
+                ->send("# nissaya\n\n{$nissayaText}\n\n");
+            $complete = time() - $startAt;
+            $content = $response['choices'][0]['message']['content'] ?? '';
+            Log::debug("ai response in {$complete}s content=" . $content);
+            $id = "{$sentence->book_id}-{$sentence->paragraph}-{$sentence->word_start}-{$sentence->word_end}";
+            $result[] = [
+                'id' => $id,
+                'content' => $content,
+            ];
+        }
+        return $result;
+    }
+
+    private function save($data)
+    {
+        //写入句子库
+        $sentData = [];
+        $sentData = array_map(function ($n) {
+            $sId = explode('-', $n['id']);
+            return [
+                'book_id' => $sId[0],
+                'paragraph' => $sId[1],
+                'word_start' => $sId[2],
+                'word_end' => $sId[3],
+                'channel_uid' => $this->workChannel['id'],
+                'content' => $n['content'],
+                'content_type' => $n['content_type'] ?? 'markdown',
+                'lang' => $this->workChannel['lang'],
+                'status' => $this->workChannel['status'],
+                'editor_uid' => $this->model['uid'],
+            ];
+        }, $data);
+        foreach ($sentData as $key => $value) {
+            $this->sentenceService->save($value);
+        }
+    }
+}

+ 60 - 0
api-v12/app/Console/Commands/UpgradeAt20230227.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+
+class UpgradeAt20230227 extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'upgrade:at20230227';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'update to 2.0';
+
+    /**
+     * 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->call('init:system.channel');
+        $this->call('init:system.dict');
+        $this->call('upgrade:dict');
+        $this->call('upgrade:dict.vocabulary');
+        $this->call('upgrade:dict.default.meaning');
+
+        //语料库
+        $this->call('init:cs6sentence');
+        $this->call('upgrade:palitext');
+        $this->call('upgrade:wbw.template');
+
+        $this->call('upgrade:related.paragraph');
+        $this->call('upgrade:fts',['--content'=>true]);
+        $this->call('upgrade:pcd.book.id');
+
+        return 0;
+    }
+}

+ 173 - 0
api-v12/app/Console/Commands/UpgradeChapterDynamic.php

@@ -0,0 +1,173 @@
+<?php
+
+namespace App\Console\Commands;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Console\Command;
+use Illuminate\Support\Carbon;
+use App\Models\SentHistory;
+use App\Models\Sentence;
+use App\Models\ProgressChapter;
+use App\Models\PaliText;
+
+class UpgradeChapterDynamic extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'upgrade:chapter.dynamic {--test}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '更新章节活跃程度svg';
+
+    /**
+     * 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:chapterdynamic start.');
+
+        $startAt = time();
+        $img_width = 600;
+        $img_height = 120;
+        $days = 300; //统计多少天
+        $min = 30;
+        $linewidth = 2;
+
+
+//更新总动态
+		$this->info("更新总动态");
+        $chapters = ProgressChapter::select('book','para')
+                                    ->groupBy('book','para')
+                                    ->orderBy('book')
+                                    ->get();
+        $bar = $this->output->createProgressBar(count($chapters));
+        foreach ($chapters as $key => $chapter) {
+            # code...
+            $max=0;
+            #章节长度
+            $paraEnd = PaliText::where('book',$chapter->book)
+                            ->where('paragraph',$chapter->para)
+                            ->value('chapter_len')+$chapter->para-1;
+
+            $svg = "<svg xmlns='http://www.w3.org/2000/svg'  fill='currentColor' viewBox='0 0 $img_width $img_height'>";
+            $svg .= "<polyline points='";
+            for ($i=$days; $i >0 ; $i--) {
+                # code...
+
+                #这一天有多少次更新
+                $count = SentHistory::whereDate('sent_histories.created_at', '=', Carbon::today()->subDays($i)->toDateString())
+                           ->leftJoin('sentences', 'sent_histories.sent_uid', '=', 'sentences.uid')
+                             ->where('book_id',$chapter->book)
+                             ->whereBetween('paragraph',[$chapter->para,$paraEnd])
+                             ->count();
+                $x=($days-$i)*($img_width/$days);
+                $y=(300-$count)*($img_height/300)-$linewidth;
+                $svg .= "{$x},{$y} ";
+            }
+            $svg .= "'  style='fill:none;stroke:green;stroke-width:{$linewidth}' /></svg>";
+            $filename = "{$chapter->book}/{$chapter->para}/globle.svg";
+            Storage::disk('local')->put("public/images/chapter_dynamic/{$filename}", $svg);
+            $bar->advance();
+
+            if($this->option('test')){
+                break; //调试代码
+            }
+
+        }
+        $bar->finish();
+
+		$time = time()- $startAt;
+        $this->info("用时 {$time}");
+
+        $startAt = time();
+
+        $this->info('更新缺的章节空白图');
+        // 更新缺的章节空白图
+        $chapters = PaliText::select('book','paragraph')
+                            ->where('level', '<', 8)
+                            ->get();
+        $bar = $this->output->createProgressBar(count($chapters));
+        $svg = "<svg xmlns='http://www.w3.org/2000/svg'  fill='currentColor' viewBox='0 0 $img_width $img_height'></svg>";
+        foreach ($chapters as $key => $chapter) {
+            $filename = "{$chapter->book}/{$chapter->paragraph}/globle.svg";
+            if(!Storage::disk('local')->exists("public/images/chapter_dynamic/{$filename}")){
+                Storage::disk('local')->put("public/images/chapter_dynamic/{$filename}", $svg);
+            }
+            $bar->advance();
+
+            if($this->option('test')){
+                break; //调试代码
+            }
+        }
+        $bar->finish();
+		$time = time()- $startAt;
+        $this->info("用时 {$time}");
+
+		$startAt = time();
+        //更新chennel动态
+        $this->info('更新chennel动态');
+        $bar = $this->output->createProgressBar(ProgressChapter::count());
+
+        foreach (ProgressChapter::select('book','para','channel_id')->cursor() as $chapter) {
+            # code...
+            $max=0;
+            #章节长度
+            $paraEnd = PaliText::where('book',$chapter->book)
+                            ->where('paragraph',$chapter->para)
+                            ->value('chapter_len')+$chapter->para-1;
+
+            $svg = "<svg xmlns='http://www.w3.org/2000/svg'  fill='currentColor' viewBox='0 0 $img_width $img_height'>";
+            $svg .= "<polyline points='";
+            for ($i=$days; $i >0 ; $i--) {
+                # code...
+
+                #这一天有多少次更新
+                $count = SentHistory::whereDate('sent_histories.created_at', '=', Carbon::today()->subDays($i)->toDateString())
+                           ->leftJoin('sentences', 'sent_histories.sent_uid', '=', 'sentences.uid')
+                             ->where('book_id',$chapter->book)
+                             ->whereBetween('paragraph',[$chapter->para,$paraEnd])
+                             ->where('sentences.channel_uid',$chapter->channel_id)
+                             ->count();
+                $x=($days-$i)*($img_width/$days);
+                $y=(300-$count)*($img_height/300)-$linewidth;
+                $svg .= "{$x},{$y} ";
+            }
+            $svg .= "'  style='fill:none;stroke:green;stroke-width:{$linewidth}' /></svg>";
+            $filename = "{$chapter->book}/{$chapter->para}/ch_{$chapter->channel_id}.svg";
+            Storage::disk('local')->put("public/images/chapter_dynamic/{$filename}", $svg);
+            $bar->advance();
+
+            if($this->option('test')){
+                break; //调试代码
+            }
+        }
+        $bar->finish();
+		$time = time()- $startAt;
+        $this->info("用时 {$time}");
+
+        $this->info("upgrade:chapterdynamic done");
+
+        return 0;
+    }
+}

+ 163 - 0
api-v12/app/Console/Commands/UpgradeChapterDynamicWeekly.php

@@ -0,0 +1,163 @@
+<?php
+
+namespace App\Console\Commands;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Console\Command;
+use Illuminate\Support\Carbon;
+use App\Models\SentHistory;
+use App\Models\Sentence;
+use App\Models\ProgressChapter;
+use App\Models\PaliText;
+use App\Tools\RedisClusters;
+use Illuminate\Support\Facades\Log;
+
+class UpgradeChapterDynamicWeekly extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'upgrade:chapter.dynamic.weekly {--test} {--book=} {--offset=}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '更新章节活跃程度svg';
+
+    /**
+     * 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('upgrade:chapter.dynamic.weekly start.');
+		$this->info('upgrade:chapter.dynamic.weekly start.');
+
+        $startAt = time();
+        $weeks = 60; //统计多少周
+
+//更新总动态
+		$this->info("更新总动态");
+		Log::debug("task:更新总动态开始");
+        $table = ProgressChapter::select('book','para')
+                                    ->groupBy('book','para')
+                                    ->orderBy('book');
+        if($this->option('book')){
+            $table = $table->where('book',$this->option('book'));
+        }
+        $chapters = $table->get();
+        $bar = $this->output->createProgressBar(count($chapters));
+        Log::debug('chapter {count} ',['count',count($chapters)]);
+        foreach ($chapters as $key => $chapter) {
+            #章节长度
+            $paraEnd = PaliText::where('book',$chapter->book)
+                            ->where('paragraph',$chapter->para)
+                            ->value('chapter_len')+$chapter->para-1;
+
+            $progress = [];
+            for ($i=$weeks; $i > 0 ; $i--) {
+                #这一周有多少次更新
+                $currDay = $i*7+$this->option('offset',0);
+                $count = SentHistory::whereBetween('sent_histories.created_at',
+                                                  [Carbon::today()->subDays($currDay)->startOfWeek(),
+                                                  Carbon::today()->subDays($currDay)->endOfWeek()])
+                           ->leftJoin('sentences', 'sent_histories.sent_uid', '=', 'sentences.uid')
+                             ->where('book_id',$chapter->book)
+                             ->whereBetween('paragraph',[$chapter->para,$paraEnd])
+                             ->count();
+                $progress[] = $count;
+            }
+            $key="/chapter_dynamic/{$chapter->book}/{$chapter->para}/global";
+            RedisClusters::put($key,$progress,3600*24*7);
+
+            $bar->advance();
+
+            if($this->option('test')){
+                $this->info("key:{$key}");
+                if(RedisClusters::has($key)){
+                    $this->info('has key '.$key);
+                }
+                break; //调试代码
+            }
+        }
+        $bar->finish();
+		Log::debug("task:更新总动态结束");
+
+		$time = time()- $startAt;
+        $this->info("用时 {$time}");
+
+        $startAt = time();
+
+		$startAt = time();
+        //更新chennel动态
+        $this->info('更新channel动态');
+		Log::debug("task:更新channel动态开始");
+
+        $table = ProgressChapter::select('book','para','channel_id');
+        if($this->option('book')){
+            $table = $table->where('book',$this->option('book'));
+        }
+        $bar = $this->output->createProgressBar($table->count());
+		Log::debug("更新channel动态 {count}",['count'=>$table->count()]);
+        foreach ($table->cursor() as $chapter) {
+            # code...
+            $max=0;
+            #章节长度
+            $paraEnd = PaliText::where('book',$chapter->book)
+                            ->where('paragraph',$chapter->para)
+                            ->value('chapter_len')+$chapter->para-1;
+            $progress = [];
+            for ($i=$weeks; $i > 0 ; $i--) {
+                #这一周有多少次更新
+                $currDay = $i*7+$this->option('offset',0);
+                $count = SentHistory::whereBetween('sent_histories.created_at',
+                                                [Carbon::today()->subDays($currDay)->startOfWeek(),
+                                                Carbon::today()->subDays($currDay)->endOfWeek()])
+                           ->leftJoin('sentences', 'sent_histories.sent_uid', '=', 'sentences.uid')
+                             ->where('book_id',$chapter->book)
+                             ->whereBetween('paragraph',[$chapter->para,$paraEnd])
+                             ->where('sentences.channel_uid',$chapter->channel_id)
+                             ->count();
+                $progress[] = $count;
+            }
+            $key="/chapter_dynamic/{$chapter->book}/{$chapter->para}/ch_{$chapter->channel_id}";
+            RedisClusters::put($key,$progress,3600*24*7);
+            $bar->advance();
+
+            if($this->option('test')){
+                $this->info("key:{$key}");
+                if(RedisClusters::has($key)){
+                    $this->info('has key '.$key);
+                }
+                break; //调试代码
+            }
+        }
+        $bar->finish();
+		Log::debug("task:更新channel动态结束");
+
+		$time = time()- $startAt;
+        $this->info("用时 {$time}");
+
+        $this->info("upgrade:chapter.dynamic done");
+        Log::debug("task: upgrade:chapter.dynamic done");
+
+        return 0;
+    }
+}

+ 178 - 0
api-v12/app/Console/Commands/UpgradeCommunityTerm.php

@@ -0,0 +1,178 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Tools\Tools;
+use App\Models\DhammaTerm;
+use App\Models\UserOperationDaily;
+use App\Models\Sentence;
+
+use App\Http\Api\ChannelApi;
+use Illuminate\Support\Str;
+
+class UpgradeCommunityTerm extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'upgrade:community.term {lang} {word?}';
+
+    /**
+     * 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;
+        }
+        $lang = strtolower($this->argument('lang'));
+        $langFamily = explode('-', $lang)[0];
+        $localTerm = ChannelApi::getSysChannel("_community_term_{$lang}_");
+        if (!$localTerm) {
+            return 1;
+        }
+
+        $channelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
+        if ($channelId === false) {
+            $this->error('no channel');
+            return 1;
+        }
+        $table = DhammaTerm::select(['word', 'tag'])
+            ->whereIn('language', [$this->argument('lang'), $lang, $langFamily])
+            ->groupBy(['word', 'tag']);
+
+        if ($this->argument('word')) {
+            $table = $table->where('word', $this->argument('word'));
+        }
+        $words = $table->get();
+        $bar = $this->output->createProgressBar(count($words));
+        foreach ($words as $key => $word) {
+            /**
+             * 最优算法
+             * 1. 找到最常见的意思
+             * 2. 找到分数最高的
+             */
+            $bestNote = "";
+            $allTerm = DhammaTerm::where('word', $word->word)
+                ->where('tag', $word->tag)
+                ->whereIn('language', [$this->argument('lang'), $lang, $langFamily])
+                ->get();
+            $score = [];
+            //$term_exp = [];
+            foreach ($allTerm as $key => $term) {
+                //获取经验值
+                $exp = UserOperationDaily::where('user_id', $term->editor_id)
+                    ->where('date_int', '<=', date_timestamp_get(date_create($term->updated_at)) * 1000)
+                    ->sum('duration');
+                $iExp = (int)($exp / 1000);
+                $noteStrLen = $term->note ? mb_strlen($term->note, 'UTF-8') : 0;
+                $paliStrLen = 0;
+                $tranStrLen = 0;
+                $noteWithoutPali = "";
+                if ($term->note && !empty(trim($term->note))) {
+                    //计算note得分
+                    //查找句子模版
+                    $pattern = "/\{\{[0-9].+?\}\}/";
+                    //获取去掉句子模版的剩余部分
+                    $noteWithoutPali = preg_replace($pattern, "", $term->note);
+                    $sentences = [];
+                    $iSent = preg_match_all($pattern, $term->note, $sentences);
+                    if ($iSent > 0) {
+                        foreach ($sentences[0] as  $sentence) {
+                            $sentId = explode("-", trim($sentence, "{}"));
+                            if (count($sentId) === 4) {
+                                $hasTran = Sentence::where('book_id', $sentId[0])
+                                    ->where('paragraph', $sentId[1])
+                                    ->where('word_start', $sentId[2])
+                                    ->where('word_end', $sentId[3])
+                                    ->exists();
+
+                                $sentLen = Sentence::where('book_id', $sentId[0])
+                                    ->where('paragraph', $sentId[1])
+                                    ->where('word_start', $sentId[2])
+                                    ->where('word_end', $sentId[3])
+                                    ->where("channel_uid", $channelId)
+                                    ->value('strlen');
+                                if ($sentLen) {
+                                    $paliStrLen += $sentLen;
+                                    if ($hasTran) {
+                                        $tranStrLen += $sentLen;
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+                //计算该术语note的总得分
+                $score["{$key}"] = $iExp * $noteStrLen;
+                //$term_exp["{$key}"] = $iExp;
+                //$updated_time["{$key}"] = $term->updated_at 先提取,具体如何使用待定
+
+            }
+
+            //需要过滤掉system_term的数量,把count(*)替换为经验值加合作为基础得分,(基础经验得分之和转化为标准活动周,再加上最近的更新时间,为最终得分)
+            $hotMeaning = DhammaTerm::selectRaw('meaning,count(*) as co')
+                ->where('word', $word->word)
+                ->whereIn('language', [$this->argument('lang'), $lang, $langFamily])
+                ->groupBy('meaning')
+                ->orderBy('co', 'desc')
+                ->first();
+            if ($hotMeaning) {
+                $bestNote = "";
+                if (count($score) > 0) {
+                    arsort($score);
+                    $bestNote = $allTerm[(int)key($score)]->note;
+                }
+
+                $term = DhammaTerm::where('channal', $localTerm)->firstOrNew(
+                    [
+                        "word" => $word->word,
+                        "tag" => $word->tag,
+                        "channal" => $localTerm,
+                    ],
+                    [
+                        'id' => app('snowflake')->id(),
+                        'guid' => Str::uuid(),
+                        'word_en' => Tools::getWordEn($word->word),
+                        'meaning' => '',
+                        'language' => $this->argument('lang'),
+                        'owner' => config("mint.admin.root_uuid"),
+                        'editor_id' => 0,
+                        'create_time' => time() * 1000,
+                    ]
+                );
+                $term->tag = $word->tag;
+                $term->meaning = $hotMeaning->meaning;
+                $term->note = $bestNote;
+                $term->modify_time = time() * 1000;
+                $term->save();
+            }
+            $bar->advance();
+        }
+        $bar->finish();
+
+        return 0;
+    }
+}

+ 341 - 0
api-v12/app/Console/Commands/UpgradeCompound.php

@@ -0,0 +1,341 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Exception;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Storage;
+use App\Models\WordIndex;
+use App\Models\WbwTemplate;
+use App\Models\UserDict;
+use Illuminate\Support\Facades\Log;
+
+use App\Tools\TurboSplit;
+use App\Http\Api\DictApi;
+use GuzzleHttp\Exception\GuzzleException;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Http;
+
+
+class UpgradeCompound extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php -d memory_limit=2024M artisan upgrade:compound  --api=https://next.wikipali.org/api --from=0 --to=500000
+     * @var string
+     */
+    protected $signature = 'upgrade:compound {word?} {--book=} {--debug} {--test} {--continue} {--api=} {--from=0} {--to=0} {--min=7} {--max=50} {--timeout=600}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'auto split compound word';
+
+    protected $MaxOneLoopTime = 120;
+
+    /**
+     * 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()) {
+            $this->info('.stop exists');
+            return 0;
+        }
+        $confirm = '';
+        if ($this->option('api')) {
+            $confirm .= 'api=' . $this->option('api') . PHP_EOL;
+        }
+        $confirm .= "min=" . $this->option('min') . PHP_EOL;
+        $confirm .= "max="  . $this->option('max') . PHP_EOL;
+        $confirm .= "from="  . $this->option('from') . PHP_EOL;
+        $confirm .= "to="  . $this->option('to') . PHP_EOL;
+
+        if (!$this->confirm($confirm)) {
+            return 0;
+        }
+        $this->info('[' . date('Y-m-d H:i:s', time()) . '] upgrade:compound start');
+
+        $dict_id = DictApi::getSysDict('robot_compound');
+        if (!$dict_id) {
+            $this->error('没有找到 robot_compound 字典');
+            return 1;
+        }
+
+        $start = \microtime(true);
+
+
+
+        //
+        if ($this->option('test')) {
+            //调试代码
+            $ts = new TurboSplit(['timeout' => $this->option('timeout')]);
+            Storage::disk('local')->put("tmp/compound.md", "# Turbo Split");
+            //获取需要拆的词
+            $list = [
+                [5, 20, 20],
+                [21, 30, 20],
+                [31, 40, 10],
+                [41, 60, 10],
+            ];
+            foreach ($list as $take) {
+                # code...
+                $words = WordIndex::where('final', 0)
+                    ->whereBetween('len', [$take[0], $take[1]])
+                    ->select('word')
+                    ->take($take[2])->get();
+                foreach ($words as $word) {
+                    $this->info($word->word);
+                    Storage::disk('local')->append("tmp/compound.md", "## {$word->word}");
+                    $parts = $ts->splitA($word->word);
+                    foreach ($parts as $part) {
+                        # code...
+                        $info = "`{$part['word']}`,{$part['factors']},{$part['confidence']}";
+                        $this->info($info);
+                        Storage::disk('local')->append("tmp/compound.md", "- {$info}");
+                    }
+                }
+            }
+            $this->info("耗时:" . \microtime(true) - $start);
+            return 0;
+        }
+
+        $_word = $this->argument('word');
+        if (!empty($_word)) {
+            $words = array((object)array('real' => $_word, 'id' => 0));
+            $total = 1;
+        } else if ($this->option('book')) {
+            $words = WbwTemplate::select('real')
+                ->where('book', $this->option('book'))
+                ->where('type', '<>', '.ctl.')
+                ->where('real', '<>', '')
+                ->orderBy('real')
+                ->groupBy('real')->cursor();
+            $query = DB::select(
+                'SELECT count(*) from (
+                                    SELECT "real" from wbw_templates where book = ? and type <> ? and real <> ? group by real) T',
+                [$this->option('book'), '.ctl.', '']
+            );
+            $total = $query[0]->count;
+        } else {
+            $min = WordIndex::min('id');
+            $max = WordIndex::max('id');
+            if ($this->option('from') > 0) {
+                $from = $min + $this->option('from');
+            } else {
+                $from = $min;
+            }
+            if ($this->option('to') > 0) {
+                $to = $min + $this->option('to');
+            } else {
+                $to = $max;
+            }
+            $table = WordIndex::whereBetween('id', [$from, $to]);
+            if ($this->option('min') > 0) {
+                $table = $table->where('len', '>=', $this->option('min'));
+            }
+            if ($this->option('max') > 0) {
+                $table = $table->where('len', '<=', $this->option('max'));
+            }
+            $total = $table->count();
+            $words = $table->orderBy('id')
+                ->selectRaw('id,word as real')
+                ->cursor();
+        }
+
+        $wordIndex = array();
+        $result = array();
+
+        $loopTime = 0;
+        foreach ($words as $key => $word) {
+            $startAt = microtime(true);
+            if (\App\Tools\Tools::isStop()) {
+                $this->info('system stop');
+                return 0;
+            }
+            $percent = (int)($key * 100 / $total);
+            if (preg_match('/\d/', $word->real)) {
+                $this->info("[{$percent}%] {$word->real} 数字不处理");
+                continue;
+            }
+            //判断数据库里面是否有
+            $exists = UserDict::where('dict_id', $dict_id)
+                ->where('word', $word->real)
+                ->exists();
+            if ($exists) {
+                $this->info("[{$percent}%]-{$key}-{$word->real}数据库中已经有了");
+                continue;
+            }
+
+            $now = date('Y-m-d H:i:s');
+            $this->info("[{$percent}%]-[{$now}]{$word->real} start id={$word->id}");
+            $wordIndex[] = $word->real;
+
+            //先查询vir数据有没有拆分
+            $parts = array();
+            $wbwWords = WbwTemplate::where('real', $word->real)
+                ->select('word')->groupBy('word')->get();
+            foreach ($wbwWords as $key => $wbwWord) {
+                if (strpos($wbwWord->word, '-') !== false) {
+                    $wbwFactors = explode('-', $wbwWord->word);
+                    //看词尾是否能找到语尾
+                    $endWord = end($wbwFactors);
+                    $endWordInDict = UserDict::where('word', $endWord)->get();
+                    foreach ($endWordInDict as $key => $oneWord) {
+                        if (
+                            !empty($oneWord->type) &&
+                            strpos($oneWord->type, 'base') === false &&
+                            $oneWord->type !== '.cp.'
+                        ) {
+                            $parts[] = [
+                                'word' => $oneWord->real,
+                                'type' => $oneWord->type,
+                                'grammar' => $oneWord->grammar,
+                                'parent' => $oneWord->parent,
+                                'factors' => implode('+', array_slice($wbwFactors, 0, -1)) . '+' . $oneWord->factors,
+                                'confidence' => 100,
+                            ];
+                        }
+                    }
+                }
+            }
+            if (count($parts) === 0) {
+                $ts = new TurboSplit(['timeout' => $this->option('timeout')]);
+                if ($this->option('debug')) {
+                    $ts->debug(true);
+                }
+                $parts = $ts->splitA($word->real);
+            } else {
+                $this->info("找到vri拆分数据:" . count($parts));
+            }
+
+            $resultCount = 0;
+            foreach ($parts as $part) {
+                if (isset($part['type']) && $part['type'] === ".v.") {
+                    continue;
+                }
+                if (empty($part['word'])) {
+                    continue;
+                }
+                $resultCount++;
+                $new = array();
+                $new['word'] = $part['word'];
+                $new['factors'] = $part['factors'];
+                if (isset($part['type'])) {
+                    $new['type'] = $part['type'];
+                } else {
+                    $new['type'] = ".cp.";
+                }
+                if (isset($part['grammar'])) {
+                    $new['grammar'] = $part['grammar'];
+                } else {
+                    $new['grammar'] = null;
+                }
+                if (isset($part['parent'])) {
+                    $new['parent'] = $part['parent'];
+                } else {
+                    $new['parent'] = null;
+                }
+                $new['confidence'] = 50 * $part['confidence'];
+                $result[] = $new;
+
+                if (!empty($_word)) {
+                    //指定拆分单词输出结果
+                    $debugOutput = [
+                        $resultCount,
+                        $part['word'],
+                        $part['type'],
+                        $part['grammar'],
+                        $part['parent'],
+                        $part['factors'],
+                        $part['confidence']
+                    ];
+                    $this->info(implode(',', $debugOutput));
+                }
+            }
+
+            $time = round(microtime(true) - $startAt, 2);
+            $loopTime += $time;
+            $this->info("[{$percent}%][{$key}] {$word->real}  {$time}s total{$loopTime}");
+
+            if ($loopTime > $this->MaxOneLoopTime) {
+                //到时间上传
+                $ok = $this->upload($wordIndex, $result, $this->option('api'));
+                if (!$ok) {
+                    Log::error('break on ' . $word->id);
+                    return 1;
+                }
+                $wordIndex = array();
+                $result = array();
+                $loopTime = 0;
+            }
+        }
+        $this->upload($wordIndex, $result, $this->option('api'));
+
+        $this->info('[' . date('Y-m-d H:i:s', time()) . '] upgrade:compound finished');
+
+        return 0;
+    }
+
+    private function upload($index, $words, $url = null)
+    {
+        if (count($words) === 0) {
+            return;
+        }
+        if (!$url) {
+            $url = config('app.url') . '/api/v2/compound';
+        } else {
+            $url = $url . '/v2/compound';
+        }
+        $this->info('url = ' . $url);
+        $this->info('uploading size=' . strlen(json_encode($words, JSON_UNESCAPED_UNICODE)));
+
+        $httpError = false;
+        $Max_Loop = 10;
+
+        try {
+            $response = Http::retry($Max_Loop, 100, function (Exception $exception) {
+                Log::error('upload fail.', ['error' => $exception]);
+                $this->error('upload fail. try again');
+                return true;
+            })
+                ->post(
+                    $url,
+                    [
+                        'index' => $index,
+                        'words' => $words,
+                    ]
+                );
+            if ($response->ok()) {
+                $this->info('upload ok');
+                $httpError = false;
+            } else {
+                $this->error('upload fail.');
+                Log::error('upload fail.' . $response->body());
+            }
+        } catch (GuzzleException $e) {
+            Log::error('send data failed', ['exception' => $e]);
+            $httpError = true;
+        }
+
+        if ($httpError) {
+            Log::error('upload fail.try max');
+            return false;
+        }
+        return true;
+    }
+}

+ 106 - 0
api-v12/app/Console/Commands/UpgradeDaily.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Log;
+
+
+class UpgradeDaily extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'upgrade:daily';
+
+    /**
+     * 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;
+        }
+        $env = config('app.env');
+        Log::info('daily task start ', ['app.env' => $env]);
+        $start = time();
+        if (app()->isLocal() == false) {
+
+            $this->call('message:webhook', [
+                'listener' => 'dingtalk',
+                'url' => 'dingtalk1',
+                'title' => "后台任务",
+                'message' => " wikipali: 每日统计后台任务开始执行。app.env=" . $env,
+            ]);
+        }
+        Log::info('wikipali: 每日统计后台任务开始执行{app.env}', ['app.env' => $env]);
+        $message = "wikipali: 每日统计后台任务执行完毕。" . $env;
+
+        //更新单词首选意思
+        $this->call('upgrade:dict.default.meaning');
+        $time = time() - $start;
+        $message .= "dict.default.meaning:{$time}; ";
+        $currTime = time();
+        Log::info('更新单词首选意思完毕' . $env);
+
+        //社区术语表
+        $this->call('upgrade:community.term', ['lang' => 'zh-Hans']);
+        $time = time() - $currTime;
+        $message .= "community.term:{$time}; ";
+        $currTime = time();
+        Log::info('社区术语表完毕 {app.env}', ['app.env' => $env]);
+
+        # 导出离线数据
+
+        $this->call('export:offline', ['format' => 'lzma', '--driver' => 'str']);
+        $time = time() - $currTime;
+        $message .= "export:offline:{$time}; ";
+        Log::info('导出离线数据完毕{app.env}', ['app.env' => $env]);
+
+        $time = time() - $start;
+        $message .= "总时间:{$time}; ";
+
+        if (app()->isLocal() === false) {
+            $this->call('message:webhook', [
+                'listener' => 'dingtalk',
+                'url' => 'dingtalk1',
+                'title' => "后台任务",
+                'message' => $message . ' app.env=' . $env,
+            ]);
+            //发送dingding消息
+            $this->call('message:webhookarticlenew', [
+                'host' => 'https://oapi.dingtalk.com/robot/send?access_token=34143dbec80a8fc09c1cb5897a5639ee3a9a32ecfe31835ad29bf7013bdb9fdf',
+                'type' => 'dingtalk',
+            ]);
+            //发送微信消息
+            $this->call('message:webhookarticlenew', [
+                'host' => 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=25dbd74f-c89c-40e5-8cbc-48b1ef7710b8',
+                'type' => 'wechat',
+            ]);
+        }
+
+        return 0;
+    }
+}

+ 227 - 0
api-v12/app/Console/Commands/UpgradeDict.php

@@ -0,0 +1,227 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Support\Str;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Storage;
+
+use App\Models\UserDict;
+use App\Models\DictInfo;
+
+class UpgradeDict extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan upgrade:dict
+     * @var string
+     */
+    protected $signature = 'upgrade:dict {uuid?} {--part}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '导入csv字典';
+
+    protected $dictInfo;
+    protected $cols;
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    private function scandict($dir)
+    {
+        if (is_dir($dir)) {
+            $this->info("scan:" . $dir);
+            if ($files = scandir($dir)) {
+                //进入目录搜索字典或子目录
+                foreach ($files as $file) {
+                    //进入语言目录循环搜索
+                    $fullPath = $dir . "/" . $file;
+                    if (is_dir($fullPath) && $file !== '.' && $file !== '..') {
+                        //是目录继续搜索
+                        $this->scandict($fullPath);
+                    } else {
+                        //是文件,查看是否是字典信息文件
+                        $infoFile = $fullPath;
+                        if (pathinfo($infoFile, PATHINFO_EXTENSION) === 'ini') {
+                            $this->dictInfo = parse_ini_file($infoFile, true);
+                            if (isset($this->dictInfo['meta']['dictname'])) {
+                                //是字典信息文件
+                                $this->info($this->dictInfo['meta']['dictname']);
+                                if (Str::isUuid($this->argument('uuid'))) {
+                                    if ($this->argument('uuid') !== $this->dictInfo['meta']['uuid']) {
+                                        continue;
+                                    }
+                                }
+                                if (!Str::isUuid($this->dictInfo['meta']['uuid'])) {
+                                    $this->error("not uuid");
+                                    continue;
+                                }
+                                //读取 description
+                                $desFile = $dir . "/description.md";
+                                if (file_exists($desFile)) {
+                                    $description = file_get_contents($desFile);
+                                } else {
+                                    $description = $this->dictInfo['meta']['description'];
+                                }
+                                $tableDict = DictInfo::firstOrNew([
+                                    "id" => $this->dictInfo['meta']['uuid']
+                                ]);
+                                $tableDict->id = $this->dictInfo['meta']['uuid'];
+                                $tableDict->name = $this->dictInfo['meta']['dictname'];
+                                $tableDict->shortname = $this->dictInfo['meta']['shortname'];
+                                $tableDict->description = $description;
+                                $tableDict->src_lang = $this->dictInfo['meta']['src_lang'];
+                                $tableDict->dest_lang = $this->dictInfo['meta']['dest_lang'];
+                                $tableDict->rows = $this->dictInfo['meta']['rows'];
+                                $tableDict->owner_id = config("mint.admin.root_uuid");
+                                $tableDict->meta = json_encode($this->dictInfo['meta']);
+                                $tableDict->save();
+
+                                if ($this->option('part')) {
+                                    $this->info(" dict id = " . $this->dictInfo['meta']['uuid']);
+                                } else {
+                                    $del = UserDict::where("dict_id", $this->dictInfo['meta']['uuid'])->delete();
+                                    $this->info("delete {$del} rows dict id = " . $this->dictInfo['meta']['uuid']);
+                                }
+                                /**
+                                 * 允许一个字典拆成若干个小文件
+                                 * 文件名 为 ***.csv , ***-1.csv , ***-2.csv
+                                 *
+                                 */
+                                $filename = $dir . '/' . pathinfo($infoFile, PATHINFO_FILENAME);
+                                $csvFile = $filename . ".csv";
+                                $count = 0;
+                                $bar = $this->output->createProgressBar($this->dictInfo['meta']['rows']);
+                                while (file_exists($csvFile)) {
+                                    # code...
+                                    $this->info("runing:{$csvFile}");
+                                    $inputRow = 0;
+                                    if (($fp = fopen($csvFile, "r")) !== false) {
+                                        $this->cols = array();
+                                        while (($data = fgetcsv($fp, 0, ',')) !== false) {
+                                            if ($inputRow == 0) {
+                                                foreach ($data as $key => $colname) {
+                                                    # 列名列表
+                                                    $this->cols[$colname] = $key;
+                                                }
+                                            } else {
+                                                if ($this->option('part')) {
+                                                    //仅仅提取拆分零件
+                                                    $word = $this->get($data, 'word');
+                                                    $factor1 = $this->get($data, 'factors');
+                                                    $factor1 = \str_replace([' ', '(', ')', '=', '-', '$'], "+", $factor1);
+                                                    foreach (\explode('+', $factor1)  as $part) {
+                                                        # code...
+                                                        if (empty($part)) {
+                                                            continue;
+                                                        }
+                                                        if (isset($newPart[$part])) {
+                                                            $newPart[$part][0]++;
+                                                        } else {
+                                                            $partExists = Cache::remember('dict/part/' . $part, config('cache.expire', 1000), function () use ($part) {
+                                                                return UserDict::where('word', $part)->exists();
+                                                            });
+                                                            if (!$partExists) {
+                                                                $count++;
+                                                                $newPart[$part] = [1, $word];
+                                                                $this->info("{$count}:{$part}-{$word}");
+                                                            }
+                                                        }
+                                                    }
+                                                } else {
+                                                    $newDict = new UserDict();
+                                                    $newDict->id = app('snowflake')->id();
+                                                    $newDict->word = $data[$this->cols['word']];
+                                                    $newDict->type = $this->get($data, 'type');
+                                                    $newDict->grammar = $this->get($data, 'grammar');
+                                                    $newDict->parent = $this->get($data, 'parent');
+                                                    $newDict->mean = $this->get($data, 'mean');
+                                                    $newDict->note = $this->get($data, 'note');
+                                                    $newDict->factors = $this->get($data, 'factors');
+                                                    $newDict->factormean = $this->get($data, 'factormean');
+                                                    $newDict->status = $this->get($data, 'status');
+                                                    $newDict->language = $this->get($data, 'language');
+                                                    $newDict->confidence = $this->get($data, 'confidence');
+                                                    $newDict->source = $this->get($data, 'source');
+                                                    $newDict->create_time = (int)(microtime(true) * 1000);
+                                                    $newDict->creator_id = 0;
+                                                    $newDict->dict_id = $this->dictInfo['meta']['uuid'];
+                                                    $newDict->save();
+                                                }
+
+                                                $bar->advance();
+                                            }
+                                            $inputRow++;
+                                        }
+                                    }
+                                    $count++;
+                                    $csvFile = $filename . "-{$count}.csv";
+                                }
+                                $bar->finish();
+                                Storage::disk('local')->put("tmp/pm-part.csv", "part,count,word");
+                                if (isset($newPart)) {
+                                    foreach ($newPart as $part => $info) {
+                                        # 写入磁盘文件
+                                        Storage::disk('local')->append("tmp/pm-part.csv", "{$part},{$info[0]},{$info[1]}");
+                                    }
+                                }
+                                $this->info("done");
+                            }
+                        }
+                    }
+                }
+                //子目录搜素完毕
+                return;
+            } else {
+                //获取子目录失败
+                $this->error("scandir fail");
+                return;
+            }
+        } else {
+            $this->error("this is not dir input={$dir}");
+            return;
+        }
+    }
+
+    /**
+     * 获取列的值
+     */
+    protected function get($data, $colname, $defualt = "")
+    {
+        if (isset($this->cols[$colname])) {
+            return $data[$this->cols[$colname]];
+        } else if (isset($this->dictInfo['cols'][$colname])) {
+            return $this->dictInfo['cols'][$colname];
+        } else {
+            return $defualt;
+        }
+    }
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if (\App\Tools\Tools::isStop()) {
+            return 0;
+        }
+        $this->info("upgrade dict start");
+        $this->scandict(config("mint.path.dict_text"));
+        $this->info("upgrade dict done");
+
+        return 0;
+    }
+}

+ 146 - 0
api-v12/app/Console/Commands/UpgradeDictDefaultMeaning.php

@@ -0,0 +1,146 @@
+<?php
+/**
+ * 刷新字典单词的默认意思
+ * 目标:
+ *    可以查询到某个单词某种语言的首选意思
+ * 算法:
+ * 1. 某种语言会有多个字典。按照字典重要程度人工排序
+ * 2. 按照顺序搜索这些字典。找到第一个意思就停止。
+ */
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\UserDict;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
+use App\Tools\RedisClusters;
+
+class UpgradeDictDefaultMeaning extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'upgrade:dict.default.meaning {word?}';
+    protected $dict = [
+        "zh-Hans"=>[
+            "8833de18-0978-434c-b281-a2e7387f69be",	/*巴汉字典明法尊者修订版*/
+            "f364d3dc-b611-471b-9a4f-531286b8c2c3",	/*《巴汉词典》Mahāñāṇo Bhikkhu编著*/
+            "0e4dc5c8-a228-4693-92ba-7d42918d8a91",	/*汉译パーリ语辞典-黃秉榮*/
+            "6aa9ec8b-bba4-4bcd-abd2-9eae015bad2b",	/*汉译パーリ语辞典-李瑩*/
+            "eb99f8b4-c3e5-43af-9102-6a93fcb97db6",	/*パーリ语辞典--勘误表*/
+            "0d79e8e8-1430-4c99-a0f1-b74f2b4b26d8",	/*《巴汉词典》增订*/
+        ],
+        "zh-Hant"=>[
+            "3acf0c0f-59a7-4d25-a3d9-bf394a266ebd",	/*汉译パーリ语辞典-黃秉榮*/
+            "5293ffb9-887e-4cf2-af78-48bf52a85304",	/*巴利詞根*/
+        ],
+        "jp"=>[
+            "91d3ec93-3811-4973-8d84-ced99179a0aa",	/*パーリ语辞典*/
+            "6d6c6812-75e7-457d-874f-5b049ad4b6de",	/*パーリ语辞典-增补*/
+        ],
+        "en"=>[
+            "c6e70507-4a14-4687-8b70-2d0c7eb0cf21",	/*	Concise P-E Dict*/
+            "eae9fd6f-7bac-4940-b80d-ad6cd6f433bf",	/*	Concise P-E Dict*/
+            "2f93d0fe-3d68-46ee-a80b-11fa445a29c6",	/*	unity*/
+            "b9163baf-2bca-41a5-a936-5a0834af3945",	/*	Pali-Dict Vri*/
+            "b089de57-f146-4095-b886-057863728c43",	/*	Buddhist Dictionary*/
+            "6afb8c05-5cbe-422e-b691-0d4507450cb7",	/*	PTS P-E dictionary*/
+            "0bfd87ec-f3ac-49a2-985e-28388779078d",	/*	Pali Proper Names Dict*/
+            "1cdc29e0-6783-4241-8784-5430b465b79c",	/*	Pāḷi Root In Saddanīti*/
+            "5718cbcf-684c-44d4-bbf2-4fa12f2588a4",	/*	Critical Pāli Dictionary*/
+        ],
+        "my"=>[
+            "e740ef40-26d7-416e-96c2-925d6650ac6b",	/*	Tipiṭaka Pāḷi-Myanmar*/
+            "beb45062-7c20-4047-bcd4-1f636ba443d1",	/*	U Hau Sein’s Pāḷi-Myanmar Dictionary*/
+            "1e299ccb-4fc4-487d-8d72-08f63d84c809",	/*	Pali Roots Dictionary*/
+            "6f9caea1-17fa-41f1-92e5-bd8e6e70e1d7",	/*	U Hau Sein’s Pāḷi-Myanmar*/
+        ],
+        "vi"=>[
+            "23f67523-fa03-48d9-9dda-ede80d578dd2",	/*	Pali Viet Dictionary*/
+            "4ac8a0d5-9c6f-4b9f-983d-84288d47f993",	/*	Pali Viet Abhi-Terms*/
+            "7c7ee287-35ba-4cf3-b87b-30f1fa6e57c9",	/*	Pali Viet Vinaya Terms*/
+        ],
+        "cm"=>[],
+    ];
+    /**
+     * 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;
+        }
+        $_word = $this->argument('word');
+        # 获取字典中所有的语言
+        $langInDict = UserDict::select('language')->groupBy('language')->get();
+        $languages = [];
+        foreach ($langInDict as $lang) {
+            if(!empty($lang["language"])){
+                $languages[] = $lang["language"];
+            }
+        }
+		//print_r($languages);
+        foreach ($this->dict as $thisLang=>$dictId) {
+            $this->info("running $thisLang");
+
+            $bar = $this->output->createProgressBar(UserDict::where('source','_PAPER_')
+                                                        ->where('language',$thisLang)->count());
+            foreach (UserDict::where('source','_PAPER_')
+                                ->where('language',$thisLang)
+                                ->select('word','note')
+                                ->cursor() as $word) {
+                if(!empty($word['note'])){
+                    RedisClusters::put("dict_first_mean/{$thisLang}/{$word['word']}", mb_substr($word['note'],0,50,"UTF-8"));
+                }
+                $bar->advance();
+            }
+            $bar->finish();
+
+            for ($i=count($dictId)-1; $i >=0 ; $i--) {
+                # code...
+                $this->info("running $thisLang - {$dictId[$i]}");
+                $count = 0;
+                foreach (UserDict::where('dict_id',$dictId[$i])
+                    ->select('word','note')
+                    ->cursor() as $word) {
+                        $cacheKey = "dict_first_mean/{$thisLang}/{$word['word']}";
+                        if(!empty($word['note'])){
+                            $cacheValue = mb_substr($word['note'],0,50,"UTF-8");
+                            if(!empty($_word) && $word['word'] === $_word ){
+                                Log::info($cacheKey.':'.$cacheValue);
+                            }
+                            RedisClusters::put($cacheKey, $cacheValue);
+                        }
+
+                        if($count % 1000 === 0){
+                            $this->info("{$count}");
+                        }
+                        $count++;
+                    }
+            }
+        }
+
+        return 0;
+    }
+}

+ 62 - 0
api-v12/app/Console/Commands/UpgradeDictId.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use App\Http\Api\DictApi;
+
+class UpgradeDictId extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'upgrade:wbw.dict.id';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '修改wbw字典id';
+
+    /**
+     * 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($this->description);
+        $user_dict_id = DictApi::getSysDict('community');
+        if($user_dict_id){
+            $result = DB::select('UPDATE "user_dicts" set "dict_id"=? where "source"=? ',[$user_dict_id,'_USER_WBW_']);
+        }else{
+            $this->error('没有找到 community 字典');
+        }
+
+        $user_dict_extract_id = DictApi::getSysDict('community_extract');
+        if($user_dict_extract_id){
+            $result = DB::select('UPDATE "user_dicts" set "dict_id"=? where "source"=? ',[$user_dict_extract_id,'_SYS_USER_WBW_']);
+        }else{
+            $this->error('没有找到 community_extract 字典');
+        }
+        $this->info('all done');
+        return 0;
+    }
+}

+ 145 - 0
api-v12/app/Console/Commands/UpgradeDictSysPreference.php

@@ -0,0 +1,145 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Http\Api\DictApi;
+use App\Models\UserDict;
+use App\Models\WordIndex;
+use Carbon\Carbon;
+
+class UpgradeDictSysPreference extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     * php artisan upgrade:dict.sys.preference
+     */
+    protected $signature = 'upgrade:dict.sys.preference';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'upgrade dict system preference';
+
+    /**
+     * 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");
+        $dictList = [
+            'community_extract',
+            'robot_compound',
+            'system_regular',
+            'system_preference',
+        ];
+        $dict_id = array();
+        foreach ($dictList as $key => $value) {
+            $dict_id[$value] = DictApi::getSysDict($value);
+            if (!$dict_id[$value]) {
+                $this->error("没有找到 {$value} 字典");
+                return 1;
+            } else {
+                $this->info("{$value} :{$dict_id[$value]}");
+            }
+        }
+        if (!$this->confirm('继续吗?')) {
+            return 0;
+        }
+        //搜索顺序
+        $order = [
+            '4d3a0d92-0adc-4052-80f5-512a2603d0e8',/* system irregular */
+            $dict_id['community_extract'],/* 社区字典*/
+            $dict_id['robot_compound'],
+            $dict_id['system_regular'],
+        ];
+        $words = WordIndex::orderBy('count', 'desc')->cursor();
+        $rows = 0;
+        $found = 0;
+        foreach ($words as $key => $word) {
+            if (preg_match('/\d/', $word->word)) {
+                //不处理带数字的
+                continue;
+            }
+            $rows++;
+            $factors = null;
+            $parent = null;
+            $confidence = 0;
+            foreach ($order as $key => $dict) {
+                //找到第一个有拆分的
+                $preWords = UserDict::where('word', $word->word)
+                    ->where('dict_id', $dict)
+                    ->orderBy('confidence', 'desc')
+                    ->get();
+                foreach ($preWords as $key => $value) {
+                    if (!$factors && !empty($value->factors)) {
+                        $factors = $value->factors;
+                        if ($value->confidence > $confidence) {
+                            $confidence = $value->confidence;
+                        }
+                    }
+                    if (!$parent && !empty($value->parent)) {
+                        $parent = $value->parent;
+                        if ($value->confidence > $confidence) {
+                            $confidence = $value->confidence;
+                        }
+                    }
+                    if ($parent && $factors) {
+                        break;
+                    }
+                }
+            }
+            if ($parent || $factors) {
+                $prefWord = UserDict::where('word', $word->word)
+                    ->where('dict_id', $dict_id['system_preference'])
+                    ->first();
+                if (!$prefWord) {
+                    $prefWord = new UserDict();
+                    $prefWord->word = $word->word;
+                    $prefWord->dict_id = $dict_id['system_preference'];
+                    $prefWord->id = app('snowflake')->id();
+                    $prefWord->source = '_ROBOT_';
+                    $prefWord->create_time = (int)(microtime(true) * 1000);
+                    $prefWord->language = 'cm';
+                    $prefWord->creator_id = 1;
+                } else {
+                    if (Carbon::parse($prefWord->updated_at) > Carbon::parse($prefWord->created_at)) {
+                        //跳过已经被编辑过的。
+                        $this->info('跳过已经被编辑过的' . $word->word);
+                        continue;
+                    }
+                }
+                $prefWord->factors = $factors;
+                $prefWord->parent = $parent;
+                $prefWord->confidence = $confidence;
+                $prefWord->save();
+                $found++;
+            }
+            if ($rows % 100 == 0) {
+                $output = "[{$rows}] {$word->word} found:{$found}";
+                $this->info($output);
+                $found = 0;
+            }
+        }
+        return 0;
+    }
+}

+ 156 - 0
api-v12/app/Console/Commands/UpgradeDictSysRegular.php

@@ -0,0 +1,156 @@
+<?php
+/**
+ * 生成系统规则变形词典
+ * 算法: 扫描字典里的所有单词。根据语尾表变形。
+ * 并在词库中查找是否在三藏中出现。出现的保存。
+ */
+namespace App\Console\Commands;
+
+use App\Models\UserDict;
+use App\Models\WbwTemplate;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\DB;
+use App\Http\Api\DictApi;
+use App\Tools\CaseMan;
+
+class UpgradeDictSysRegular extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan upgrade:regular jāta
+     * @var string
+     */
+    protected $signature = 'upgrade:regular {word?} {--debug}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'upgrade regular';
+    /**
+     * 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;
+        }
+        $dict_id = DictApi::getSysDict('system_regular');
+        if(!$dict_id){
+            $this->error('没有找到 system_regular 字典');
+            return 1;
+        }else{
+            $this->info("system_regular :{$dict_id}");
+        }
+
+		if(empty($this->argument('word'))){
+			$words = UserDict::where('type','.n:base.')
+							->orWhere('type','.v:base.')
+							->orWhere('type','.adj:base.')
+							->orWhere('type','.ti:base.');
+            $init = UserDict::where('dict_id',$dict_id)
+                            ->update(['flag'=>0]);
+		}else{
+			$words = UserDict::where('word',$this->argument('word'))
+							->where(function($query) {
+								$query->where('type','.n:base.')
+								->orWhere('type','.v:base.')
+								->orWhere('type','.adj:base.')
+								->orWhere('type','.ti:base.');
+							});
+            $init = UserDict::where('dict_id',$dict_id)
+                            ->where('word',$this->argument('word'))
+                            ->update(['flag'=>0]);
+		}
+		$words = $words->select(['word','type','grammar'])
+						->groupBy(['word','type','grammar'])
+						->orderBy('word');
+		$query = "
+		select count(*) from (select count(*) from user_dicts ud where
+			\"type\" = '.v:base.' or
+			\"type\" = '.n:base.' or
+			\"type\" = '.ti:base.' or
+			\"type\" = '.adj:base.'
+			group by word,type,grammar) as t;
+		";
+		$count = DB::select($query);
+		$bar = $this->output->createProgressBar($count[0]->count);
+        $caseMan = new CaseMan();
+		foreach ($words->cursor() as $word) {
+            if($this->option('debug')){$this->info("{$word->word}:{$word->type}");}
+            $newWords = $caseMan->Declension($word->word,$word->type,$word->grammar,0.5);
+            if($this->option('debug')){$this->info("{$word->word}:".count($newWords));}
+            foreach ($newWords as $newWord) {
+                if(isset($newWord['type'])){
+                    $type = $newWord['type'];
+                }else{
+                    $type = \str_replace(':base','',$word->type);
+                }
+
+                $new = UserDict::firstOrNew(
+                    [
+                        'word' => $newWord['word'],
+                        'type' => $type,
+                        'grammar' => $newWord['grammar'],
+                        'parent' => $word->word,
+                        'factors' => $newWord['factors'],
+                        'dict_id' => $dict_id,
+                    ],
+                    [
+                        'id' => app('snowflake')->id(),
+                        'source' => '_ROBOT_',
+                        'create_time'=>(int)(microtime(true)*1000)
+                    ]
+                );
+                $new->confidence = 80;
+                $new->language = 'cm';
+                $new->creator_id = 1;
+                $new->flag = 1;
+                $new->save();
+            }
+
+			$bar->advance();
+		}
+		$bar->finish();
+        if(!empty($this->argument('word'))){
+			$declensions = UserDict::where('dict_id',$dict_id)
+                            ->where('parent',$this->argument('word'))
+                            ->select('word')
+                            ->groupBy('word')
+                            ->get();
+            foreach ($declensions as $key => $word) {
+                Log::debug($word->word);
+            }
+		}
+		//删除旧数据
+		$table = UserDict::where('dict_id',$dict_id);
+		if(!empty($this->argument('word'))){
+			$table = $table->where('parent',$this->argument('word'));
+		}
+		$table->where('flag',0)->delete();
+
+        //DB::enableQueryLog();
+        $newRecord = UserDict::where('dict_id',$dict_id);
+		if(!empty($this->argument('word'))){
+			$newRecord = $newRecord->where('parent',$this->argument('word'));
+		}
+		$newRecord->where('flag',1)->update(['flag'=>0]);
+        //print_r(DB::getQueryLog());
+        return 0;
+    }
+}

+ 139 - 0
api-v12/app/Console/Commands/UpgradeDictSysWbwExtract.php

@@ -0,0 +1,139 @@
+<?php
+/**
+ * 将用户词典中的数据进行汇总。
+ * 算法:
+ * 同样词性的合并为一条记录。意思按照出现的次数排序
+ */
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\UserDict;
+use App\Http\Api\DictApi;
+
+class UpgradeDictSysWbwExtract extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'upgrade:syswbwextract';
+
+    /**
+     * 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;
+        }
+        $user_dict_id = DictApi::getSysDict('community');
+        if(!$user_dict_id){
+            $this->error('没有找到 community 字典');
+            return 1;
+        }
+        $user_dict_extract_id = DictApi::getSysDict('community_extract');
+        if(!$user_dict_extract_id){
+            $this->error('没有找到 community_extract 字典');
+            return 1;
+        }
+		$dict  = UserDict::select('word')->where('word','!=','')->where('dict_id',$user_dict_id)->groupBy('word');
+		$bar = $this->output->createProgressBar($dict->count());
+		foreach ($dict->cursor() as  $word) {
+			# code...
+			//case
+			$wordtype = '';
+			$wordgrammar = '';
+			$wordparent = '';
+			$wordfactors = '';
+
+			$case = UserDict::selectRaw('type,grammar, sum(confidence)')
+					->where('word',$word->word)
+					->where('dict_id',$user_dict_id)
+					->where('type','!=','.part.')
+					->where('type','<>','')
+					->whereNotNull('type')
+					->groupBy(['type','grammar'])
+					->orderBy('sum','desc')
+					->first();
+			if($case){
+				$wordtype = $case->type;
+				$wordgrammar = $case->grammar;
+			}
+
+			//parent
+			$parent = UserDict::selectRaw('parent, sum(confidence)')
+					->where('word',$word->word)
+					->where('dict_id',$user_dict_id)
+					->where('type','!=','.part.')
+					->where('parent','!=','')
+					->whereNotNull('parent')
+					->groupBy('parent')
+					->orderBy('sum','desc')
+					->first();
+			if($parent){
+				$wordparent = $parent->parent;
+			}
+
+
+				//factors
+				$factor = UserDict::selectRaw('factors, sum(confidence)')
+						->where('word',$word->word)
+						->where('dict_id',$user_dict_id)
+						->where('type','!=','.part.')
+						->where('factors','<>','')
+						->whereNotNull('factors')
+						->groupBy('factors')
+						->orderBy('sum','desc')
+						->first();
+				if($factor){
+					$wordfactors = $factor->factors;
+				}
+				$new = UserDict::firstOrNew(
+					[
+						'word' => $word->word,
+						'type' => $wordtype,
+						'grammar' => $wordgrammar,
+						'parent' => $wordparent,
+						'factors' => $wordfactors,
+						'dict_id' => $user_dict_extract_id,
+					],
+					[
+						'id' => app('snowflake')->id(),
+						'source' => '_ROBOT_',
+						'create_time'=>(int)(microtime(true)*1000)
+					]
+				);
+				$new->confidence = 90;
+				$new->language = 'cm';
+				$new->creator_id = 1;
+				$new->flag = 1;
+				$new->save();
+
+				$bar->advance();
+		}
+		$bar->finish();
+
+        //TODO 删除旧数据
+        return 0;
+    }
+}

+ 64 - 0
api-v12/app/Console/Commands/UpgradeDictVocabulary.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\Vocabulary;
+use App\Models\UserDict;
+use App\Tools\Tools;
+
+class UpgradeDictVocabulary extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan upgrade:dict.vocabulary
+     * @var string
+     */
+    protected $signature = 'upgrade:dict.vocabulary';
+
+    /**
+     * 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;
+        }
+        $words = UserDict::where('source','_PAPER_')->selectRaw('word,count(*)')->groupBy('word')->cursor();
+
+		$bar = $this->output->createProgressBar(230000);
+		foreach ($words as $word) {
+			$update = Vocabulary::firstOrNew(
+                ['word' => $word->word],
+                ['word_en'=>Tools::getWordEn($word->word)]
+            );
+            $update->count = $word->count;
+            $update->flag = 1;
+            $update->strlen = mb_strlen($word->word,"UTF-8");
+            $update->save();
+            $bar->advance();
+		}
+        $bar->finish();
+        Vocabulary::where('flag',0)->delete();
+        return 0;
+    }
+}

+ 149 - 0
api-v12/app/Console/Commands/UpgradeFts.php

@@ -0,0 +1,149 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\BookTitle;
+use App\Models\FtsText;
+use App\Models\WbwTemplate;
+use App\Tools\PaliSearch;
+
+class UpgradeFts extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'upgrade:fts {--content : upgrade col content only}
+        {para?}
+        {--test : output log only}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'upgrade full text search table';
+
+    /**
+     * 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;
+        }
+
+        if(!empty($this->argument('para'))){
+            $para = explode('-',$this->argument('para'));
+        }
+        for ($iBook=1; $iBook <= 217; $iBook++) {
+            if(isset($para[0]) && $para[0] != $iBook){
+                continue;
+            }
+            # code...
+            $this->info('book:'.$iBook);
+            $maxParagraph = WbwTemplate::where('book',$iBook)->max('paragraph');
+            $bar = $this->output->createProgressBar($maxParagraph-1);
+            for($iPara=1; $iPara <= $maxParagraph; $iPara++){
+                if(isset($para[1]) && $para[1] != $iPara){
+                    $bar->advance();
+                    continue;
+                }
+                $content = $this->getContent($iBook,$iPara);
+                //查找黑体字
+                $words = WbwTemplate::where('book',$iBook)
+                                    ->where('paragraph',$iPara)
+                                    ->orderBy('wid')->get();
+                $bold1 = array();
+                $bold2 = array();
+                $bold3 = array();
+                $currBold = array();
+                foreach ($words as $word) {
+                    if($word->style==='bld'){
+                        $currBold[] = $word->real;
+                    }else{
+                        $countBold = count($currBold);
+                        if($countBold === 1){
+                            $bold1[] = $currBold[0];
+                        }else if($countBold === 2){
+                            $bold2 = array_merge($bold2,$currBold);
+                        }else if($countBold > 0){
+                            $bold3 = array_merge($bold3,$currBold);
+                        }
+                        $currBold = [];
+                    }
+                }
+                $pcd_book = BookTitle::where('book',$iBook)
+                        ->where('paragraph','<=',$iPara)
+                        ->orderBy('paragraph','desc')
+                        ->first();
+                if($pcd_book){
+                    $pcd_book_id = $pcd_book->sn;
+                }else{
+                    $pcd_book_id = BookTitle::where('book',$iBook)
+                                            ->orderBy('paragraph')
+                                            ->value('sn');
+                }
+                if($this->option('test')){
+                    $this->info($content.
+                                ' pcd_book='.$pcd_book_id.
+                                ' bold1='.implode(' ',$bold1).
+                                ' bold2='.implode(' ',$bold2).
+                                ' bold3='.implode(' ',$bold3).PHP_EOL
+                                );
+                }else{
+                    $update = PaliSearch::update($iBook,
+                                                 $iPara,
+                                                implode(' ',$bold1),
+                                                implode(' ',$bold2),
+                                                implode(' ',$bold3),
+                                                $content,
+                                                $pcd_book_id);
+                }
+                $bar->advance();
+            }
+            $bar->finish();
+            $this->info('done');
+        }
+
+        return 0;
+    }
+
+    private function getContent($book,$para){
+        $words = WbwTemplate::where('book',$book)
+                            ->where('paragraph',$para)
+                            ->where('type',"<>",".ctl.")
+                            ->orderBy('wid')->get();
+        $content = '';
+        foreach ($words as  $word) {
+            if($word->style === 'bld'){
+                if(strpos($word->word,"{")===FALSE){
+                    $content .= "**{$word->word}** ";
+                }else{
+                    $content .= str_replace(['{','}'],['**','** '],$word->word);
+                }
+            }else if($word->style === 'note'){
+                $content .= " _{$word->word}_ ";
+            }else{
+                $content .= $word->word . " ";
+            }
+        }
+        return $content;
+    }
+}
+
+

+ 141 - 0
api-v12/app/Console/Commands/UpgradeGrammarBook.php

@@ -0,0 +1,141 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Http;
+
+use App\Http\Api\ChannelApi;
+use App\Models\DhammaTerm;
+use App\Models\Relation;
+use App\Tools\Tools;
+
+class UpgradeGrammarBook extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan upgrade:grammar.book
+     * @var string
+     */
+    protected $signature = 'upgrade:grammar.book';
+
+    /**
+     * 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()
+    {
+        $lang = 'zh-Hans';
+        $channelId = ChannelApi::getSysChannel('_System_Grammar_Term_'.strtolower($lang).'_');
+        if($channelId === false){
+            $this->error('no channel');
+            return 1;
+        }
+
+        $relations = Relation::get();
+        $result = [];
+        foreach ($relations as $key => $relation) {
+            $from = json_decode($relation->from,true);
+            $words = [];
+            if(isset($from['spell']) && !empty($from['spell'])){
+                $words[] =  $from['spell'];
+            }
+            if(isset($from['case']) && count($from['case'])>0){
+                $words = array_merge($words,$from['case']);
+            }
+            if(count($words)===0){
+                continue;
+            }
+            $word = implode('.',$words);
+            if(!isset($result[$word])){
+                $result[$word] = array();
+            }
+            $result[$word][] = $relation;
+        }
+
+        foreach ($result as $key => $rows) {
+            $this->info('## '.$key);
+
+            $caseLocal = DhammaTerm::where('channal',$channelId)
+                                ->where('word',$key)
+                                ->value('meaning');
+            if($caseLocal){
+                $title = "**[[{$key}]]** 用法表\n\n";
+            }else{
+                $title = "**{$key}** 用法表\n\n";
+            }
+            $relations = [];
+            foreach ($rows as $row) {
+                if(!isset($relations[$row['name']])){
+                    $relations[$row['name']] = array();
+                    $local = DhammaTerm::where('channal',$channelId)
+                                        ->where('word',$row['name'])
+                                        ->first();
+                    if($local){
+                        $relations[$row['name']]['meaning']=$local->meaning;
+                        $relations[$row['name']]['note']=$local->note;
+                    }else{
+                        $relations[$row['name']]['meaning']='';
+                        $relations[$row['name']]['note']='';
+                    }
+                    $relations[$row['name']]['to']=array();
+                }
+                $relations[$row['name']]['to'][] = $row['to'];
+            }
+            $table = "|名称|解释|\n";
+            $table .= "| -- | -- |\n";
+            foreach ($relations as $relation => $value) {
+                $table .= "| [[{$relation}]] | ". $value['note']." |\n";
+            }
+            $table .= "\n\n";
+            echo $title.$table;
+            //更新字典
+            $newWord = $key.'.relations';
+            $new = DhammaTerm::firstOrNew([
+                                'channal' => $channelId,
+                                'word' => $newWord,
+                            ],[
+                                'id'=>app('snowflake')->id(),
+                                'guid'=>Str::uuid(),
+                                'create_time'=>time()*1000,
+                            ]);
+            if(empty($caseLocal)){
+                $caseLocal = $key;
+            }
+            $owner = ChannelApi::getById($channelId);
+            if(!$owner){
+                $this->error('channel id error '.$channelId);
+                continue;
+            }
+            $new->word_en = strtolower($newWord);
+            $new->meaning =$caseLocal.'用法表';
+            $new->note = $table;
+            $new->language = $lang;
+            $new->editor_id = 1;
+            $new->owner = $owner['studio_id'];
+            $new->modify_time = time()*1000;
+            $new->save();
+        }
+
+        return 0;
+    }
+}

+ 75 - 0
api-v12/app/Console/Commands/UpgradePageNumber.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\WbwTemplate;
+use App\Models\PageNumber;
+
+class UpgradePageNumber extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'upgrade:page.number';
+
+    /**
+     * 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;
+        }
+        $table = WbwTemplate::where('type','.ctl.')->orderBy('book')->orderBy('paragraph')->cursor();
+        $pageHead = ['M','P','T','V','O'];
+        $bar = $this->output->createProgressBar(WbwTemplate::where('type','.ctl.')->count());
+        foreach ($table as $key => $value) {
+            $type = substr($value->word,0,1);
+            if(in_array($type,$pageHead)){
+                $arrPage = explode('.',$value->word);
+                if(count($arrPage)!==2){
+                    continue;
+                }
+                $page = PageNumber::firstOrNew(
+                    [
+                        'book'=>$value->book,
+                        'paragraph'=>$value->paragraph,
+                        'wid'=>$value->wid,
+                    ],
+                    [
+                        'type'=>$type,
+                        'volume'=>(int)substr($arrPage[0],1),
+                        'page'=>(int)$arrPage[1],
+                        'pcd_book_id'=>$value->pcd_book_id,
+                    ]
+                    );
+                    $page->save();
+            }
+            $bar->advance();
+        }
+        $bar->finish();
+        return 0;
+    }
+}

+ 253 - 0
api-v12/app/Console/Commands/UpgradePaliText.php

@@ -0,0 +1,253 @@
+<?php
+
+/**
+ * 计算章节的父子,前后关系
+ * 输入: csv文件
+ */
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\PaliText;
+use App\Models\BookTitle;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class UpgradePaliText extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan upgrade:palitext 168
+     * @var string
+     */
+    protected $signature = 'upgrade:palitext {from?} {to?}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'upgrade pali_texts paragraph infomation';
+
+    /**
+     * 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 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) {
+            }
+        }
+
+        for ($from = $_from; $from <= $_to; $from++) {
+            $this->info("book: " . $from);
+            $inputRow = 0;
+            $arrInserString = array();
+            #载入csv数据
+            $FileName = $filelist[$from - 1][1];
+            $csvFile = config("mint.path.pali_title") . '/' . $from . '_pali.csv';
+            if (($fp = fopen($csvFile, "r")) !== false) {
+                Log::info("csv load:" . $csvFile);
+                while (($data = fgetcsv($fp, 0, ',')) !== false) {
+                    if ($inputRow > 0) {
+                        array_push($arrInserString, $data);
+                    }
+                    $inputRow++;
+                }
+                fclose($fp);
+            } else {
+                $this->error("can not open csv file. filename=" . $csvFile . PHP_EOL);
+                Log::error("can not open csv file. filename=" . $csvFile);
+                continue;
+            }
+            $title_data = PaliText::select(['book', 'paragraph', 'level', 'parent', 'toc', 'lenght'])
+                ->where('book', $from)->orderby('paragraph', 'asc')->get();
+
+            $paragraph_count = count($title_data);
+            $paragraph_info = array();
+            $paragraph_info[] = array($from, -1, $paragraph_count, -1, -1, -1);
+
+
+            for ($iPar = 0; $iPar < count($title_data); $iPar++) {
+                $title_data[$iPar]["level"] = $arrInserString[$iPar][3];
+            }
+
+            for ($iPar = 0; $iPar < count($title_data); $iPar++) {
+                $book = $from;
+                $paragraph = $title_data[$iPar]["paragraph"];
+                $true_level = (int) $title_data[$iPar]["level"];
+
+                if ((int) $title_data[$iPar]["level"] == 8) {
+                    $title_data[$iPar]["level"] = 100;
+                }
+                $curr_level = (int) $title_data[$iPar]["level"];
+                # 计算这个chapter的段落数量
+                $length = -1;
+
+
+                for ($iPar1 = $iPar + 1; $iPar1 < count($title_data); $iPar1++) {
+                    $thislevel = (int) $title_data[$iPar1]["level"];
+                    if ($thislevel <= $curr_level) {
+                        $length = (int) $title_data[$iPar1]["paragraph"] - $paragraph;
+                        break;
+                    }
+                }
+
+                if ($length == -1) {
+                    $length = $paragraph_count - $paragraph + 1;
+                }
+
+                /*
+                上一个段落
+                算法:查找上一个标题段落。而且该标题段落的下一个段落不是标题段落
+                */
+                $prev = -1;
+                if ($iPar > 0) {
+                    for ($iPar1 = $iPar - 1; $iPar1 >= 0; $iPar1--) {
+                        if ($title_data[$iPar1]["level"] < 8 && $title_data[$iPar1 + 1]["level"] == 100) {
+                            $prev = $title_data[$iPar1]["paragraph"];
+                            break;
+                        }
+                    }
+                }
+                /*
+                下一个段落
+                算法:查找下一个标题段落。而且该标题段落的下一个段落不是标题段落
+                */
+                $next = -1;
+                if ($iPar < count($title_data) - 1) {
+                    for ($iPar1 = $iPar + 1; $iPar1 < count($title_data) - 1; $iPar1++) {
+                        if ($title_data[$iPar1]["level"] < 8 && $title_data[$iPar1 + 1]["level"] == 100) {
+                            $next = $title_data[$iPar1]["paragraph"];
+                            break;
+                        }
+                    }
+                }
+                //查找parent
+                $parent = -1;
+                if ($iPar > 0) {
+                    for ($iPar1 = $iPar - 1; $iPar1 >= 0; $iPar1--) {
+                        if ($title_data[$iPar1]["level"] < $true_level) {
+                            $parent = $title_data[$iPar1]["paragraph"];
+                            break;
+                        }
+                    }
+                }
+                //计算章节包含总字符数
+                $iChapter_strlen = 0;
+
+                for ($i = $iPar; $i < $iPar + $length; $i++) {
+                    $iChapter_strlen += $title_data[$i]["lenght"];
+                }
+
+                $newData = [
+                    'level' => $arrInserString[$iPar][3],
+                    'toc' => $arrInserString[$iPar][5],
+                    'chapter_len' => $length,
+                    'next_chapter' => $next,
+                    'prev_chapter' => $prev,
+                    'parent' => $parent,
+                    'chapter_strlen' => $iChapter_strlen,
+                ];
+                if ((int)$arrInserString[$iPar][3] < 8) {
+                    $newData['title'] = strtolower($arrInserString[$iPar][6]);
+                    $newData['title_en'] = \App\Tools\Tools::getWordEn($newData['title']);
+                }
+
+                $path = [];
+
+                $title_data[$iPar]["level"] = $newData["level"];
+                $title_data[$iPar]["toc"] = $newData["toc"];
+                $title_data[$iPar]["parent"] = $newData["parent"];
+
+                /*
+                *获取路径
+                */
+                $currParent = $parent;
+
+                $iLoop = 0;
+                while ($currParent != -1 && $iLoop < 7) {
+                    # code...
+                    $pathTitle = $title_data[$currParent - 1]["toc"];
+                    $pathLevel = $title_data[$currParent - 1]['level'];
+                    $path[] = ["book" => $book, "paragraph" => $currParent, "title" => $pathTitle, "level" => $pathLevel];
+                    $currParent = $title_data[$currParent - 1]["parent"];
+                    $iLoop++;
+                }
+
+                //插入书名
+                if (count($path) > 0) {
+                    $bookPara = end($path)['paragraph'];
+                } else {
+                    $bookPara = $paragraph;
+                }
+
+                $pcd_book = BookTitle::where('book', $book)
+                    ->where('paragraph', $bookPara)
+                    ->first();
+                if ($pcd_book) {
+                    if (empty($pcd_book)) {
+                        Log::error('no pcd book:' . $book . '-' . $bookPara);
+                    }
+                    $book_id = $pcd_book->sn;
+                    if (!empty($book_id)) {
+                        $newData['pcd_book_id'] = $book_id;
+                    }
+                    $path[] = ["book" => $book_id, "paragraph" => $book_id, "title" => $pcd_book->title, "level" => 0];
+                }
+
+                # 将路径反向
+                $path1 = [];
+                for ($i = count($path) - 1; $i >= 0; $i--) {
+                    # code...
+                    $path1[] = $path[$i];
+                }
+                $newData['path'] = $path1;
+
+
+                PaliText::where('book', $book)
+                    ->where('paragraph', $paragraph)
+                    ->update($newData);
+
+                if ($curr_level > 0 && $curr_level < 8) {
+                    $paragraph_info[] = array($book, $paragraph, $length, $prev, $next, $parent);
+                }
+            }
+        }
+
+        $this->info("all done in " . time() - $startTime . "s");
+        Log::info("all done in  " . time() - $startTime . "s");
+        return 0;
+    }
+}

+ 75 - 0
api-v12/app/Console/Commands/UpgradePaliTextId.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\PaliText;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class UpgradePaliTextId extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'upgrade:palitextid';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'upgrade pali text uuid';
+
+    /**
+     * 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 text uuid");
+        $startTime = time();
+
+        $bar = $this->output->createProgressBar(PaliText::count());
+        #载入csv数据
+        $csvFile = config("mint.path.pali_title") .'/pali_text_uuid.csv';
+        if (($fp = fopen($csvFile, "r")) === false) {
+            $this->error( "can not open csv file. filename=" . $csvFile. PHP_EOL) ;
+            Log::error( "can not open csv file. filename=" . $csvFile) ;
+        }
+        Log::info("csv load:" . $csvFile);
+        $inputRow=0;
+        while (($data = fgetcsv($fp, 0, ',')) !== false) {
+            if ($inputRow > 0) {
+                PaliText::where('book',$data[0])
+                        ->where('paragraph',$data[1])
+                        ->update(['uid'=>$data[2]]);
+            }
+            $inputRow++;
+            $bar->advance();
+
+        }
+        fclose($fp);
+        $bar->finish();
+        $this->info("mission finished. in ". time()-$startTime . "s");
+		Log::info("mission finished. in ". time()-$startTime . "s");
+
+        return 0;
+    }
+}

+ 111 - 0
api-v12/app/Console/Commands/UpgradePaliTextTag.php

@@ -0,0 +1,111 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\PaliText;
+use App\Models\Tag;
+use App\Models\TagMap;
+use Illuminate\Support\Facades\Log;
+
+class UpgradePaliTextTag extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'upgrade:palitexttag {book?}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'upgrade palitext tag';
+
+    /**
+     * 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 text tag");
+        $startTime = time();
+
+        #载入csv数据
+        $csvFile = config("mint.path.pali_title") . '/pali_text_tag.csv';
+        if (($fp = fopen($csvFile, "r")) === false) {
+            $this->error("can not open csv file. filename=" . $csvFile . PHP_EOL);
+            Log::error("can not open csv file. filename=" . $csvFile);
+        }
+        Log::info("csv load:" . $csvFile);
+        $inputRow = 0;
+        $tagCount = 0;
+        while (($data = fgetcsv($fp, 0, ',')) !== false) {
+            $inputRow++;
+            if ($inputRow % 100 == 0) {
+                $this->info($inputRow);
+            }
+
+            //略过第一行标题行
+            if ($inputRow == 1) {
+                continue;
+            }
+            /*测试第一行
+            if($inputRow > 2) {
+                break;
+            }
+            */
+            $book = $data[0];
+            if (!empty($this->argument('book'))) {
+                if ($book != (int)$this->argument('book')) {
+                    continue;
+                }
+            }
+            $para = $data[1];
+            $tags = explode(':', $data[4]);
+            $paliTextUuid = PaliText::where("book", $book)->where("paragraph", $para)->value('uid');
+            if ($paliTextUuid) {
+                //删除旧数据
+                $tagMapDelete = TagMap::where('table_name', 'pali_texts')
+                    ->where('anchor_id', $paliTextUuid)
+                    ->delete();
+                foreach ($tags as $key => $tag) {
+                    # code...
+                    if (!empty($tag)) {
+                        $tagRow = Tag::firstOrCreate(['name' => $tag], ['owner_id' => config("mint.admin.root_uuid")]);
+                        $tagMap = TagMap::firstOrCreate([
+                            'table_name' => 'pali_texts',
+                            'anchor_id' => $paliTextUuid,
+                            'tag_id' => $tagRow->id
+                        ]);
+                        if ($tagMap) {
+                            $tagCount++;
+                        }
+                    }
+                }
+            } else {
+                $this->error("no palitext uuid book=$book para=$para ");
+            }
+        }
+        fclose($fp);
+        $this->info(" $inputRow para $tagCount tags  finished. in " . time() - $startTime . "s");
+        Log::info("$inputRow para $tagCount tags  finished. in " . time() - $startTime . "s");
+        return 0;
+    }
+}

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio