Просмотр исходного кода

Merge pull request #2368 from visuddhinanda/development

Development
visuddhinanda 5 дней назад
Родитель
Сommit
a261e6bdd6

+ 2 - 2
README.md

@@ -1,6 +1,6 @@
-# International Academy Of Pali Tipitaka(国际巴利三藏学院)
+# wikipali project
 
-这是一个开放的基于语料库的巴利语学习和翻译平台。
+这是一个开放的基于语料库的巴利语学习和翻译平台。 
 
 ## Usage
 

+ 1 - 1
api-v13/app/Console/Commands/ExportOffline.php

@@ -15,7 +15,7 @@ class ExportOffline extends Command
      * php artisan export:offline lzma
      * @var string
      */
-    protected $signature = 'export:offline {format?  : zip file format 7z,lzma,gz } {--shortcut}  {--driver=morus}';
+    protected $signature = 'export:offline {format?  : zip file format 7z,lzma,gz } {--shortcut}  {--driver=str}';
 
     /**
      * The console command description.

+ 23 - 11
api-v13/app/Console/Commands/IndexTipitaka.php

@@ -21,9 +21,11 @@ class IndexTipitaka extends Command
      * php artisan opensearch:index-tipitaka 93 --para=6 --granularity=chapter
      * @var string
      */
-    protected $signature = 'opensearch:index-tipitaka {book : The book ID to index data for}
-    {--test}
+    protected $signature = 'opensearch:index-tipitaka
+    {book : The book ID to index data for}
     {--para= : index paragraph No. omit to all}
+    {--channel= : index channel id omit to all}
+    {--test}
     {--summary=on}
     {--resume}
     {--granularity=all : The granularity to index (paragraph, sutta, sentence; omit to index all)}';
@@ -60,9 +62,14 @@ class IndexTipitaka extends Command
      */
     public function handle()
     {
+        $this->line('index tipitaka start');
         $book = (int)$this->argument('book');
-        $granularity = $this->option('granularity');
         $paragraph = $this->option('para');
+        $channel = $this->option('channel');
+        if ($channel) {
+            $this->line('channel=' . $channel);
+        }
+        $granularity = $this->option('granularity');
         $this->summary = $this->option('summary') === 'on';
 
         if ($this->option('test')) {
@@ -93,7 +100,7 @@ class IndexTipitaka extends Command
                     $this->option('granularity') === 'chapter' ||
                     $this->option('granularity') === 'all'
                 ) {
-                    $this->indexChapter($bookId);
+                    $this->indexChapter($bookId, $channel);
                 }
                 if (
                     $this->option('granularity') === 'paragraph' ||
@@ -233,14 +240,14 @@ class IndexTipitaka extends Command
             'resource_id' => $paraInfo['uid'], // Use uid from getPaliData for resource_id
             'resource_type' => 'original_text',
             'title' => [
-                ['text'=>['pali' => "{$currChapter} paragraph {$paraInfo['paragraph']}"]]
-                
+                ['text' => ['pali' => "{$currChapter} paragraph {$paraInfo['paragraph']}"]]
+
             ],
             'summary' => [
                 'text' => $this->summary ? $this->summaryService->summarize($content['markdown']) : ''
             ],
             'content' => [
-                ['text'=>['pali' => implode("\n\n", $markdown)]]
+                ['text' => ['pali' => implode("\n\n", $markdown)]]
             ],
             'bold_single' => implode(" ", $bold_single),
             'bold_multi' => implode(" ", $bold_multi),
@@ -266,9 +273,10 @@ class IndexTipitaka extends Command
      * Index Pali suttas for a given book (placeholder for future implementation).
      *
      * @param int $book
+     * @param ?string $channel
      * @return int
      */
-    protected function indexChapter($book)
+    protected function indexChapter($book, $channelId = null)
     {
         $this->info("Starting to index paragraphs for book: $book");
         $total = 0;
@@ -292,10 +300,14 @@ class IndexTipitaka extends Command
                 $end = $chapters[$key + 1]->paragraph - 1;
             }
             //获取这个段落之间的全部channel
-            $channels = Sentence::where('book_id', $book)
-                ->whereBetween('paragraph', [$start, $end])
-                ->select('channel_uid')
+            $table = Sentence::where('book_id', $book)
+                ->whereBetween('paragraph', [$start, $end]);
+            if ($channelId) {
+                $table = $table->where('channel_uid', $channelId);
+            }
+            $channels = $table->select('channel_uid')
                 ->groupBy('channel_uid')->get();
+
             $this->info("index chapter start={$start} end={$end}");
 
             foreach ($channels as $channel) {

+ 137 - 15
api-v13/app/Console/Commands/UpdateCorpus.php

@@ -2,14 +2,21 @@
 
 namespace App\Console\Commands;
 
+use Illuminate\Support\Facades\Log;
+
+
 use App\Services\SentenceService;
+use App\Services\TermService;
 use Illuminate\Console\Attributes\Description;
 use Illuminate\Console\Attributes\Signature;
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\DB;
 use App\Models\Channel;
 
-#[Signature('app:update-corpus')]
+use App\Http\Api\UserApi;
+
+
+#[Signature('app:update-corpus --dir= --es')]
 #[Description('Update corpus from JSONL files in corpus directory')]
 class UpdateCorpus extends Command
 {
@@ -20,15 +27,18 @@ class UpdateCorpus extends Command
      */
     protected SentenceService $sentenceService;
 
+    protected TermService $termService;
+
     /**
      * Create a new command instance.
      *
      * @param SentenceService $sentenceService
      */
-    public function __construct(SentenceService $sentenceService)
+    public function __construct(SentenceService $sentenceService, TermService $termService)
     {
         parent::__construct();
         $this->sentenceService = $sentenceService;
+        $this->termService = $termService;
     }
 
     /**
@@ -41,7 +51,11 @@ class UpdateCorpus extends Command
         $this->info('Starting corpus update process...');
 
         // Get the corpus base path from config
-        $corpusBasePath = config('mint.path.corpus');
+        if ($this->option('dir')) {
+            $corpusBasePath = $this->option('dir');
+        } else {
+            $corpusBasePath = config('mint.path.corpus');
+        }
 
         if (!is_dir($corpusBasePath)) {
             $this->error("Corpus directory not found: {$corpusBasePath}");
@@ -49,34 +63,55 @@ class UpdateCorpus extends Command
         }
 
         // Scan subdirectories of the corpus path
-        $subdirectories = $this->getSubdirectories($corpusBasePath);
+        $stores = $this->getSubdirectories($corpusBasePath);
 
-        if (empty($subdirectories)) {
+        if (empty($stores)) {
             $this->warn('No subdirectories found in corpus path.');
             return self::SUCCESS;
         }
 
-        $this->info("Found " . count($subdirectories) . " subdirectories to process.");
+        $this->info("Found " . count($stores) . " subdirectories to process.");
 
         $totalProcessed = 0;
         $totalErrors = 0;
 
-        foreach ($subdirectories as $subdir) {
-            $this->info("Processing directory: {$subdir}");
+        foreach ($stores as $store) {
+            $this->info("Processing directory: {$store}");
 
             try {
-                $stats = $this->processCorpusDirectory($subdir);
+                $stats = $this->processCorpusDirectory($store);
                 $totalProcessed += $stats['processed'];
                 $totalErrors += $stats['errors'];
                 $this->info("Directory processed: {$stats['processed']} records saved, {$stats['errors']} errors");
+                if ($this->option('es') && isset($stats['channels'])) {
+                    foreach ($stats['channels'] as $key => $channelId) {
+                        $this->call('upgrade:progress', ['--channel' => $channelId]);
+                        $this->call('upgrade:progress.chapter', ['--channel' => $channelId]);
+                        $this->call('opensearch:index-tipitaka', [
+                            'book' => 0,
+                            '--channel' => $channelId,
+                            '--granularity' => 'chapter',
+                            '--summary' => 'off'
+                        ]);
+                    }
+                }
             } catch (\Exception $e) {
-                $this->error("Failed to process directory {$subdir}: {$e->getMessage()}");
+                $this->error("Failed to process directory {$store}: {$e->getMessage()}");
+                Log::error("Failed to process directory", [
+                    'dir'        => $store,
+                    'message'    => $e->getMessage(),
+                    'file'       => $e->getFile(),
+                    'line'       => $e->getLine(),
+                    'trace'      => $e->getTraceAsString(),
+                ]);
                 $totalErrors++;
             }
         }
 
         $this->info("Corpus update completed. Total processed: {$totalProcessed}, Total errors: {$totalErrors}");
 
+
+
         return $totalErrors > 0 ? self::FAILURE : self::SUCCESS;
     }
 
@@ -147,6 +182,13 @@ class UpdateCorpus extends Command
 
         $this->info("Found {$channels->count()} channel(s) for source ID: {$sourceId}");
 
+        $glossaryFile = $directoryPath . DIRECTORY_SEPARATOR . 'glossary.csv';
+
+        if (file_exists($glossaryFile)) {
+            $status = $this->processGlossary($glossaryFile, $channels);
+            $this->line('glossary load');
+        }
+
         // Scan subdirectories of the current directory for JSONL files
         $childDirectories = $this->getSubdirectories($directoryPath);
 
@@ -161,10 +203,89 @@ class UpdateCorpus extends Command
                 $stats['errors'] += $fileStats['errors'];
             }
         }
-
+        $stats['channels'] = array_map(fn($item) => $item['uid'], $channels->toArray());
         return $stats;
     }
+    /**
+     * Process a glossary csv file and save glossary for each channel.
+     *
+     * @param string $filePath
+     * @param \Illuminate\Database\Eloquent\Collection $channels
+     * @return array
+     */
+    protected function processGlossary(string $filePath, $channels): array
+    {
+        $stats = [
+            'processed' => 0,
+            'errors'    => 0,
+        ];
+
+        $handle = fopen($filePath, 'r');
+
+        if (!$handle) {
+            $this->error("Failed to open file: {$filePath}");
+            return $stats;
+        }
+
+        $robotUid = config('mint.admin.robot_uuid');
+
+        if (!$robotUid) {
+            $this->error('robot_uuid not configured in mint.admin.robot_uid');
+            fclose($handle);
+            return $stats;
+        }
+
+        // 读取表头行
+        $headers = fgetcsv($handle);
+
+        if ($headers === false) {
+            $this->error("Failed to read CSV headers from: {$filePath}");
+            fclose($handle);
+            return $stats;
+        }
+
+        $lineNumber = 0;
+
+        while (($row = fgetcsv($handle)) !== false) {
+            $lineNumber++;
+
+            if (count($row) !== count($headers)) {
+                $this->error("Column count mismatch at line {$lineNumber} in file: {$filePath}");
+                $stats['errors']++;
+                continue;
+            }
+
+            $data = array_combine($headers, $row);
+            $editor_id = UserApi::getIdByUuid($robotUid);
+            foreach ($channels as $channel) {
+                try {
+                    $saveData = [
+                        'word'          => $data['pali_word'],
+                        'tag'           => $data['tag'] ?? null,
+                        'channel_id'    => $channel->uid,
+                        'meaning'       => $data['meaning'],
+                        'redirect'       => $data['redirect'] ?? null,
+                        'other_meaning' => $data['meaning2'] ?: null,
+                        'note'          => $data['note'] ?: null,
+                        'editor_id'     => $editor_id,
+                    ];
 
+                    DB::transaction(function () use ($saveData) {
+                        $this->termService->updateOrCreateByWord($saveData);
+                    });
+
+                    $stats['processed']++;
+                } catch (\Exception $e) {
+                    $this->error("Failed to save glossary for channel {$channel->uid} at line {$lineNumber}: {$e->getMessage()}");
+                    $stats['errors']++;
+                }
+            }
+        }
+
+        fclose($handle);
+        $this->line("glossary {$lineNumber} lines processed");
+        return $stats;
+    }
     /**
      * Process a single JSONL file and save records for each channel.
      *
@@ -215,11 +336,12 @@ class UpdateCorpus extends Command
             // Save for each channel
             foreach ($channels as $channel) {
                 try {
+                    [$book, $para, $start, $end] = explode('-', $data['id']);
                     $saveData = [
-                        'book_id' => $data['book'],
-                        'paragraph' => $data['paragraph'],
-                        'word_start' => $data['start'],
-                        'word_end' => $data['end'],
+                        'book_id' => $book,
+                        'paragraph' => $para,
+                        'word_start' => $start,
+                        'word_end' => $end,
                         'content' => $data['content'],
                         'channel_uid' => $channel->uid,
                         'editor_uid' => $robotUid,

+ 46 - 46
api-v13/app/Console/Commands/UpgradeChapterDynamic.php → api-v13/app/Console/Commands/UpgradeChapterDynamicDaily.php

@@ -1,6 +1,7 @@
 <?php
 
 namespace App\Console\Commands;
+
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Console\Command;
 use Illuminate\Support\Carbon;
@@ -9,14 +10,14 @@ use App\Models\Sentence;
 use App\Models\ProgressChapter;
 use App\Models\PaliText;
 
-class UpgradeChapterDynamic extends Command
+class UpgradeChapterDynamicDaily extends Command
 {
     /**
      * The name and signature of the console command.
      *
      * @var string
      */
-    protected $signature = 'upgrade:chapter.dynamic {--test}';
+    protected $signature = 'upgrade:chapter.dynamic.daily {--test}';
 
     /**
      * The console command description.
@@ -42,10 +43,10 @@ class UpgradeChapterDynamic extends Command
      */
     public function handle()
     {
-        if(\App\Tools\Tools::isStop()){
+        if (\App\Tools\Tools::isStop()) {
             return 0;
         }
-		$this->info('upgrade:chapterdynamic start.');
+        $this->info('upgrade:chapterdynamic start.');
 
         $startAt = time();
         $img_width = 600;
@@ -55,34 +56,34 @@ class UpgradeChapterDynamic extends Command
         $linewidth = 2;
 
 
-//更新总动态
-		$this->info("更新总动态");
-        $chapters = ProgressChapter::select('book','para')
-                                    ->groupBy('book','para')
-                                    ->orderBy('book')
-                                    ->get();
+        //更新总动态
+        $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;
+            $max = 0;
             #章节长度
-            $paraEnd = PaliText::where('book',$chapter->book)
-                            ->where('paragraph',$chapter->para)
-                            ->value('chapter_len')+$chapter->para-1;
+            $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--) {
+            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;
+                    ->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>";
@@ -90,67 +91,66 @@ class UpgradeChapterDynamic extends Command
             Storage::disk('local')->put("public/images/chapter_dynamic/{$filename}", $svg);
             $bar->advance();
 
-            if($this->option('test')){
+            if ($this->option('test')) {
                 break; //调试代码
             }
-
         }
         $bar->finish();
 
-		$time = time()- $startAt;
+        $time = time() - $startAt;
         $this->info("用时 {$time}");
 
         $startAt = time();
 
         $this->info('更新缺的章节空白图');
         // 更新缺的章节空白图
-        $chapters = PaliText::select('book','paragraph')
-                            ->where('level', '<', 8)
-                            ->get();
+        $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}")){
+            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')){
+            if ($this->option('test')) {
                 break; //调试代码
             }
         }
         $bar->finish();
-		$time = time()- $startAt;
+        $time = time() - $startAt;
         $this->info("用时 {$time}");
 
-		$startAt = time();
+        $startAt = time();
         //更新chennel动态
         $this->info('更新chennel动态');
         $bar = $this->output->createProgressBar(ProgressChapter::count());
 
-        foreach (ProgressChapter::select('book','para','channel_id')->cursor() as $chapter) {
+        foreach (ProgressChapter::select('book', 'para', 'channel_id')->cursor() as $chapter) {
             # code...
-            $max=0;
+            $max = 0;
             #章节长度
-            $paraEnd = PaliText::where('book',$chapter->book)
-                            ->where('paragraph',$chapter->para)
-                            ->value('chapter_len')+$chapter->para-1;
+            $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--) {
+            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;
+                    ->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>";
@@ -158,12 +158,12 @@ class UpgradeChapterDynamic extends Command
             Storage::disk('local')->put("public/images/chapter_dynamic/{$filename}", $svg);
             $bar->advance();
 
-            if($this->option('test')){
+            if ($this->option('test')) {
                 break; //调试代码
             }
         }
         $bar->finish();
-		$time = time()- $startAt;
+        $time = time() - $startAt;
         $this->info("用时 {$time}");
 
         $this->info("upgrade:chapterdynamic done");

+ 7 - 120
api-v13/app/Console/Commands/UpgradeProgress.php

@@ -2,134 +2,21 @@
 
 namespace App\Console\Commands;
 
+use Illuminate\Console\Attributes\Description;
+use Illuminate\Console\Attributes\Signature;
 use Illuminate\Console\Command;
-use App\Models\Sentence;
-use App\Models\PaliSentence;
-use App\Models\Progress;
-use App\Models\ProgressChapter;
-use App\Models\PaliText;
-use Illuminate\Support\Facades\Log;
 
+#[Signature('app:upgrade-progress')]
+#[Description('Command description')]
 class UpgradeProgress extends Command
 {
-    /**
-     * The name and signature of the console command.
-     * php artisan upgrade:progress --book=168 --para=916 --channel=19f53a65-81db-4b7d-8144-ac33f1217d34
-     * @var string
-     */
-    protected $signature = 'upgrade:progress {--book=} {--para=} {--channel=} {--resume}';
-
-    /**
-     * 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:progress start');
-        $startTime = time();
-        $book = $this->option('book');
-        $para = $this->option('para');
-        $channelId = $this->option('channel');
-        if ($book && $para && $channelId) {
-                $sentences = Sentence::where('strlen', '>', 0)
-                ->where('book_id', $book)
-                ->where('paragraph', $para)
-                ->where('channel_uid', $channelId)
-                ->groupby('book_id', 'paragraph', 'channel_uid')
-                ->select('book_id', 'paragraph', 'channel_uid');
-        } else {
-            if($this->option('resume')){
-                $sentences = Sentence::where('strlen', '>', 0)
-                ->whereBetween('book_id', [$book,1000])
-                ->where('paragraph','>=', $para)
-                ->whereNotNull('channel_uid')
-                ->groupby('book_id', 'paragraph', 'channel_uid')
-                ->select('book_id', 'paragraph', 'channel_uid');
-            }else{
-                $sentences = Sentence::where('strlen', '>', 0)
-                ->where('book_id', '<', 1000)
-                ->whereNotNull('channel_uid')
-                ->groupby('book_id', 'paragraph', 'channel_uid')
-                ->select('book_id', 'paragraph', 'channel_uid');
-            }
-
-        }
-        $count = $sentences->count();
-        $sentences = $sentences->cursor();
-        $this->info('sentences:' . $count);
-        #第二步 更新段落表
-        foreach ($sentences as $sentence) {
-            # 第二步 生成para progress 1,2,15,zh-tw
-            # 计算此段落完成时间
-            $finalAt = Sentence::where('strlen', '>', 0)
-                ->where('book_id', $sentence->book_id)
-                ->where('paragraph', $sentence->paragraph)
-                ->where('channel_uid', $sentence->channel_uid)
-                ->max('created_at');
-            $updateAt = Sentence::where('strlen', '>', 0)
-                ->where('book_id', $sentence->book_id)
-                ->where('paragraph', $sentence->paragraph)
-                ->where('channel_uid', $sentence->channel_uid)
-                ->max('updated_at');
-            # 查询每个段落的等效巴利语字符数
-            $result_sent = Sentence::where('strlen', '>', 0)
-                ->where('book_id', $sentence->book_id)
-                ->where('paragraph', $sentence->paragraph)
-                ->where('channel_uid', $sentence->channel_uid)
-                ->select('word_start')
-                ->get();
-            if (count($result_sent) > 0) {
-                #查询这些句子的总共等效巴利语字符数
-                $para_strlen = 0;
-                foreach ($result_sent as $sent) {
-                    # code...
-                    $para_strlen += PaliSentence::where('book', $sentence->book_id)
-                        ->where('paragraph', $sentence->paragraph)
-                        ->where('word_begin', $sent->word_start)
-                        ->value('length');
-                }
-                $paraInfo = [
-                    'book' => $sentence->book_id,
-                    'para' => $sentence->paragraph,
-                    'channel_id' => $sentence->channel_uid
-                ];
-                $paraData = [
-                    'lang' => 'en',
-                    'all_strlen' => $para_strlen,
-                    'public_strlen' => $para_strlen,
-                    'created_at' => $finalAt,
-                    'updated_at' => $updateAt,
-                ];
-                //Log::debug('Progress updateOrInsert', ['para' => $paraInfo, 'data' => $paraData]);
-                $this->info('Progress updateOrInsert'.json_encode($paraInfo));
-                Progress::updateOrInsert($paraInfo, $paraData);
-            }
-        }
-
-        $time = time() - $startTime;
-        $this->info("upgrade progress finished in {$time}s");
-
-        return 0;
+        //
+        $this->call('upgrade:progress.para');
+        $this->call('upgrade:progress.chapter');
     }
 }

+ 3 - 0
api-v13/app/Console/Commands/UpgradeProgressChapter.php

@@ -64,6 +64,9 @@ class UpgradeProgressChapter extends Command
         $book = $this->option('book');
         $para = $this->option('para');
         $channelId = $this->option('channel');
+        if ($channelId) {
+            $this->line('channel=' . $channelId);
+        }
 
         \App\Tools\Markdown::driver($this->option('driver'));
 

+ 143 - 0
api-v13/app/Console/Commands/UpgradeProgressPara.php

@@ -0,0 +1,143 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use App\Models\Sentence;
+use App\Models\PaliSentence;
+use App\Models\Progress;
+use App\Models\ProgressChapter;
+use App\Models\PaliText;
+use Illuminate\Support\Facades\Log;
+
+class UpgradeProgressPara extends Command
+{
+    /**
+     * The name and signature of the console command.
+     * php artisan upgrade:progress --book=152 --channel=19f53a65-81db-4b7d-8144-ac33f1217d34
+     * @var string
+     */
+    protected $signature = 'upgrade:progress.para {--book=} {--para=} {--channel=} {--resume}';
+
+    /**
+     * 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:progress start');
+        $startTime = time();
+        $book = $this->option('book');
+        $para = $this->option('para');
+        $channelId = $this->option('channel');
+        if ($channelId) {
+            $this->line('channel=' . $channelId);
+        }
+        $table = Sentence::where('strlen', '>', 0);
+        if ($book || $para || $channelId) {
+            if ($book) {
+                $table = $table->where('book_id', $book);
+            }
+            if ($para) {
+                $table = $table->where('paragraph', $para);
+            }
+            if ($channelId) {
+                $table = $table->where('channel_uid', $channelId);
+            }
+            $sentences = $table->groupby('book_id', 'paragraph', 'channel_uid')
+                ->select('book_id', 'paragraph', 'channel_uid');
+        } else {
+            if ($this->option('resume')) {
+                $sentences = Sentence::where('strlen', '>', 0)
+                    ->whereBetween('book_id', [$book, 1000])
+                    ->where('paragraph', '>=', $para)
+                    ->whereNotNull('channel_uid')
+                    ->groupby('book_id', 'paragraph', 'channel_uid')
+                    ->select('book_id', 'paragraph', 'channel_uid');
+            } else {
+                $sentences = Sentence::where('strlen', '>', 0)
+                    ->where('book_id', '<', 1000)
+                    ->whereNotNull('channel_uid')
+                    ->groupby('book_id', 'paragraph', 'channel_uid')
+                    ->select('book_id', 'paragraph', 'channel_uid');
+            }
+        }
+        $count = $sentences->count();
+        $sentences = $sentences->cursor();
+        $this->info('sentences:' . $count);
+        #第二步 更新段落表
+        foreach ($sentences as $sentence) {
+            # 第二步 生成para progress 1,2,15,zh-tw
+            # 计算此段落完成时间
+            $finalAt = Sentence::where('strlen', '>', 0)
+                ->where('book_id', $sentence->book_id)
+                ->where('paragraph', $sentence->paragraph)
+                ->where('channel_uid', $sentence->channel_uid)
+                ->max('created_at');
+            $updateAt = Sentence::where('strlen', '>', 0)
+                ->where('book_id', $sentence->book_id)
+                ->where('paragraph', $sentence->paragraph)
+                ->where('channel_uid', $sentence->channel_uid)
+                ->max('updated_at');
+            # 查询每个段落的等效巴利语字符数
+            $result_sent = Sentence::where('strlen', '>', 0)
+                ->where('book_id', $sentence->book_id)
+                ->where('paragraph', $sentence->paragraph)
+                ->where('channel_uid', $sentence->channel_uid)
+                ->select('word_start')
+                ->get();
+            if (count($result_sent) > 0) {
+                #查询这些句子的总共等效巴利语字符数
+                $para_strlen = 0;
+                foreach ($result_sent as $sent) {
+                    # code...
+                    $para_strlen += PaliSentence::where('book', $sentence->book_id)
+                        ->where('paragraph', $sentence->paragraph)
+                        ->where('word_begin', $sent->word_start)
+                        ->value('length');
+                }
+                $paraInfo = [
+                    'book' => $sentence->book_id,
+                    'para' => $sentence->paragraph,
+                    'channel_id' => $sentence->channel_uid
+                ];
+                $paraData = [
+                    'lang' => 'en',
+                    'all_strlen' => $para_strlen,
+                    'public_strlen' => $para_strlen,
+                    'created_at' => $finalAt,
+                    'updated_at' => $updateAt,
+                ];
+                //Log::debug('Progress updateOrInsert', ['para' => $paraInfo, 'data' => $paraData]);
+                $this->info('Progress updateOrInsert' . json_encode($paraInfo));
+                Progress::updateOrInsert($paraInfo, $paraData);
+            }
+        }
+
+        $time = time() - $startTime;
+        $this->info("upgrade progress finished in {$time}s");
+
+        return 0;
+    }
+}

+ 61 - 0
api-v13/app/Services/TermService.php

@@ -7,6 +7,9 @@ use App\Http\Api\ChannelApi;
 use App\Http\Resources\TermResource;
 use Illuminate\Http\Request;
 
+use App\Tools\Tools;
+
+
 
 class TermService
 {
@@ -194,4 +197,62 @@ class TermService
     {
         DhammaTerm::where('guid', $id)->update($data);
     }
+
+    /**
+     * @param array{
+     *     word: string,
+     *     tag: string,
+     *     channal: string,
+     *     meaning: string,
+     *     other_meaning: string|null,
+     *     note: string|null,
+     *     editor_id: int,
+     * } $data
+     * @return string 返回记录的 id
+     */
+    public function updateOrCreateByWord(array $data): string
+    {
+        $now = time();
+
+        $channelInfo = ChannelApi::getById($data['channel_id']);
+
+        // 先查询是否存在
+        $term = DhammaTerm::where('word', $data['word'])
+            ->where('tag', $data['tag'] ?? null)
+            ->where('channal', $data['channel_id'])
+            ->first();
+
+        if ($term) {
+            // 已存在,直接更新
+            $term->update([
+                'meaning'       => $data['meaning'],
+                'other_meaning' => $data['other_meaning'] ?? null,
+                'note'          => $data['note'] ?? null,
+                'redirect'      => $data['redirect'] ?? null,
+                'editor_id'     => $data['editor_id'],
+                'modify_time'   => $now,
+            ]);
+        } else {
+            // 不存在,新建(一次性写入所有字段)
+            $term = new DhammaTerm();
+            $term->id           = app('snowflake')->id();
+            $term->guid         = (string) \Illuminate\Support\Str::uuid();
+            $term->word         = $data['word'];
+            $term->tag          = $data['tag'] ?? null;
+            $term->channal      = $data['channel_id'];
+            $term->meaning      = $data['meaning'];
+            $term->other_meaning = $data['other_meaning'] ?? null;
+            $term->note         = $data['note'] ?? null;
+            $term->redirect     = $data['redirect'] ?? null;
+            $term->editor_id    = $data['editor_id'];  // 注意:需传入 int 类型的 editor id
+            $term->owner        = $channelInfo['studio_id'];
+            $term->word_en      = Tools::getWordEn($data['word']);
+            $term->language     = $channelInfo['lang'] ?? 'zh-Hans';
+            $term->create_time  = $now;
+            $term->modify_time  = $now;
+            $term->save();
+        }
+
+        return $term->guid;
+    }
 }

+ 34 - 0
api-v13/database/migrations/2026_05_12_084851_add_redirect_to_dhamma_terms.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('dhamma_terms', function (Blueprint $table) {
+            $table->string('redirect', 1024)
+                ->nullable()->comment('重定向到另一个术语');
+
+            // 添加普通索引
+            $table->index('redirect');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('dhamma_terms', function (Blueprint $table) {
+            //
+            $table->dropIndex(['redirect']);
+            $table->dropColumn('redirect');
+        });
+    }
+};

+ 66 - 0
api-v13/documents/tasks.md

@@ -0,0 +1,66 @@
+# task list
+
+## MQ workers
+
+```bash
+php artisan mq:discussion
+
+php artisan mq:export.pali.chapter
+
+php artisan mq:export.article
+
+php artisan mq:progress
+
+php artisan mq:task
+
+php artisan mq:wbw.analyses
+
+```
+
+## every five minutes
+
+```bash
+php artisan app:index-open-search
+```
+
+## daily
+
+start at: 16:00 UTC
+
+```bash
+# 更新单词首选意思
+php artisan upgrade:dict.default.meaning
+
+# 社区术语表
+php artisan upgrade:community.term zh-Hans
+
+# 导出离线数据
+php artisan export:offline lzma
+
+```
+
+## weekly
+
+start at: Mon 16:00 UTC
+
+```bash
+# update corpus
+# dir is option omit to storage/resources
+php artisan app:update-corpus --dir=***
+
+# index tipitaka
+php artisan opensearch:index-tipitaka 0 --granularity=chapter
+
+# index term
+php artisan opensearch:index-term
+
+# 逐词译数据库分析
+php artisan upgrade:wbw.analyses
+
+# 段落更新图
+php artisan app:upgrade-progress
+
+# 段落更新图
+php artisan upgrade:chapter.dynamic.weekly
+
+```

+ 1 - 1
api-v13/storage/resources

@@ -1 +1 @@
-Subproject commit 594dce58f24087fd453c4d16375afe20d01fb92f
+Subproject commit c2fe20a05d3a2acb35455030143ebfba9721e130