瀏覽代碼

Merge pull request #2451 from visuddhinanda/development

Development
visuddhinanda 2 周之前
父節點
當前提交
75a43c8153

+ 20 - 58
api-v13/app/Console/Commands/IndexTipitaka.php

@@ -12,9 +12,8 @@ use App\Services\SearchPaliDataService;
 use App\Services\SummaryService;
 use App\Services\TagService;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Cache;
-
+use Illuminate\Support\Facades\Log;
 
 class IndexTipitaka extends Command
 {
@@ -322,66 +321,29 @@ class IndexTipitaka extends Command
 
                     continue;
                 }
-                $paragraphsData = app(PaliContentService::class)->paragraphs(
-                    $book,
-                    $start,
-                    $end,
-                    [$channel->channel_uid],
-                    ['mode' => 'read', 'format' => 'html', 'original' => false]
-                );
+                $paraList = Sentence::where('book_id', $book)
+                    ->whereBetween('paragraph', [$start, $end])
+                    ->where('channel_uid', $channel->channel_uid)
+                    ->orderBy('paragraph')
+                    ->distinct()->pluck('paragraph');
                 // 生成html数据
 
                 $title = '';
-                foreach ($paragraphsData as $paragraph) {
-                    $translation = [];
-                    $original = [];
-                    foreach ($paragraph['children'] as $sent) {
-                        $sid = "{$sent['book']}-{$sent['para']}-{$sent['wordStart']}-{$sent['wordEnd']}";
-                        if (isset($sent['translation'])) {
-                            foreach ($sent['translation'] as $tran) {
-                                if ($tran['channel']['id'] === $channel->channel_uid) {
-                                    $html = $tran['html'] ?? $tran['content'];
-                                    $translation[] = "<div class='sentence' data-sid='{$sid}'>{$html}</div>";
-                                    if ($tran['para'] === $start && ! empty($html)) {
-                                        $title = $html;
-                                    }
-                                }
-                            }
-                        }
-                        if (
-                            isset($sent['origin']) ||
-                            is_array($sent['origin']) ||
-                            count($sent['origin']) > 0
-                        ) {
-                            foreach ($sent['origin'] as $origin) {
-                                if ($origin['channel']['id'] === $channel->channel_uid) {
-                                    $html = $origin['html'] ?? $origin['content'];
-                                    $original[] = "<div class='sentence origin'  data-sid='{$sid}'>{$html}</div>";
-                                    if (empty($title) && $origin['para'] === $start && ! empty($html)) {
-                                        $title = $html;
-                                    }
-                                }
-                            }
-                        }
-                    }
-
-                    $level = $paragraph['para'] === $start ? $chapter->level : 0;
-                    $strOriginal = implode('', $original);
-                    $strTranslation = implode('', $translation);
-
-                    if ($channelInfo['type'] === 'original') {
-                        $htmlContent = $strOriginal;
-                    } else {
-                        $htmlContent = $strTranslation;
+                foreach ($paraList as $para) {
+                    $para = (int) $para;
+                    $paragraph = app(PaliContentService::class)->readParagraph(
+                        $book,
+                        $para,
+                        $channel->channel_uid,
+                        'html'
+                    );
+                    if (empty($paragraph['display'])) {
+                        continue;
                     }
-
-                    $area = $channelInfo['type'] === 'original' ? 'original' : 'translation';
-
-                    if ($level > 0) {
-                        $display[] = "<div class='{$area}' data-para='{$paragraph['para']}'><h{$level}>{$htmlContent}</h{$level}></div>";
-                    } else {
-                        $display[] = "<div class='{$area}' data-para='{$paragraph['para']}'><div class='para-block'>{$htmlContent}</div></div>";
+                    if ($para === $start && empty($title)) {
+                        $title = $paragraph['sentences'][0]['html'];
                     }
+                    $display[] = $paragraph['display'];
                 }
                 $this->chapterSave([
                     'book' => $book,
@@ -433,7 +395,7 @@ class IndexTipitaka extends Command
             $document['title']['text']['pali'] = $title;
         }
         $document['content']['display'] = $param['content'];             // 展示
-        Cache::put($docId,$param['content']);
+        Cache::put($docId, $param['content']);
 
         if ($this->isTest) {
             $this->info($param['content']);

+ 100 - 0
api-v13/app/Http/Controllers/TipitakaContentParaController.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Services\PaliContentService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+
+class TipitakaContentParaController extends Controller
+{
+    /**
+     * 阅读模式段落内容列表。指定 book 段落区间和 channel
+     */
+    public function index(Request $request, PaliContentService $paliService): JsonResponse
+    {
+        $data = $request->validate([
+            'book' => 'required|integer',
+            'para' => 'required|integer',
+            'to' => 'integer',
+            'channel' => 'required|uuid',
+            'format' => 'string|in:html,markdown,react,text',
+            'view' => 'string|in:display,sentences,all',
+        ]);
+
+        $from = $data['para'];
+        $to = $data['to'] ?? $from;
+        if ($to < $from) {
+            return $this->error('invalid paragraph range');
+        }
+        $format = $data['format'] ?? 'html';
+        $view = $data['view'] ?? 'display';
+
+        $items = [];
+        foreach (range($from, $to) as $para) {
+            $paragraph = $paliService->readParagraph(
+                (int) $data['book'],
+                (int) $para,
+                $data['channel'],
+                $format
+            );
+            if (empty($paragraph['display'])) {
+                continue;
+            }
+            $items[] = $this->filterView($paragraph, $view);
+        }
+
+        return $this->ok([
+            'items' => $items,
+            'pagination' => [
+                'page' => 1,
+                'pageSize' => $to - $from + 1,
+                'total' => count($items),
+            ],
+        ]);
+    }
+
+    /**
+     * 单个段落内容。id 格式 {book}-{para}
+     */
+    public function show(Request $request, string $id, PaliContentService $paliService): JsonResponse
+    {
+        $arrId = explode('-', $id);
+        if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
+            return $this->error('invalid id');
+        }
+        $channel = $request->input('channel');
+        if (! Str::isUuid($channel)) {
+            return $this->error('invalid channel');
+        }
+        $book = (int) $arrId[0];
+        $para = (int) $arrId[1];
+
+        $paragraph = $paliService->readParagraph(
+            $book,
+            $para,
+            $channel,
+            $request->input('format', 'html')
+        );
+        if (empty($paragraph['display'])) {
+            return $this->error('no data');
+        }
+
+        return $this->ok($this->filterView($paragraph, $request->input('view', 'display')));
+    }
+
+    /**
+     * 按 view 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。
+     *
+     * @param  array{para: int, display: string, sentences: array}  $paragraph
+     */
+    protected function filterView(array $paragraph, string $view): array
+    {
+        return match ($view) {
+            'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']],
+            'all' => $paragraph,
+            default => ['para' => $paragraph['para'], 'display' => $paragraph['display']],
+        };
+    }
+}

+ 16 - 0
api-v13/app/Models/Sentence.php

@@ -2,6 +2,7 @@
 
 namespace App\Models;
 
+use App\Services\PaliContentService;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\SoftDeletes;
@@ -36,6 +37,21 @@ class Sentence extends Model
         'channel_uid' => 'string',
     ];
 
+    protected static function booted(): void
+    {
+        $forget = function (Sentence $sentence) {
+            PaliContentService::forgetParagraph(
+                (int) $sentence->book_id,
+                (int) $sentence->paragraph,
+                (string) $sentence->channel_uid
+            );
+        };
+        static::saved($forget);
+        static::deleted($forget);
+        static::restored($forget);
+        static::forceDeleted($forget);
+    }
+
     protected $dates = [
         'created_at',
         'updated_at',

+ 126 - 0
api-v13/app/Services/PaliContentService.php

@@ -642,4 +642,130 @@ class PaliContentService
 
         return $result;
     }
+
+    /**
+     * 阅读模式渲染单个段落。
+     * 直接从 sentences 表取单个 channel 的记录,渲染成 html。
+     * 与 paragraphs() 不同:没有译文的句子不保留占位,有多少句子输出多少句子。
+     *
+     * @return array{para: int, display: string, sentences: array<int, array{sid: string, html: string}>}
+     */
+    public function readParagraph(int $book, int $para, string $channelUid, string $format = 'html'): array
+    {
+        $level = $this->paragraphLevel($book, $para);
+        // 缓存句子,段落外壳与标题级别有关,不进缓存
+        $key = self::paragraphCacheKey($book, $para, $channelUid, $format);
+        $cached = Cache::tags([self::paragraphCacheTag($book, $para, $channelUid)])
+            ->rememberForever($key, function () use ($book, $para, $channelUid, $format) {
+                return $this->renderReadSentences($book, $para, $channelUid, $format);
+            });
+
+        $result = [
+            'para' => $para,
+            'display' => '',
+            'sentences' => $cached['sentences'],
+        ];
+        if (count($cached['display']) === 0) {
+            return $result;
+        }
+
+        if ($format === 'html') {
+            // html 格式加段落外壳
+            $content = implode('', $cached['display']);
+            $inner = $level > 0 ? "<h{$level}>{$content}</h{$level}>" : "<div class='para-block'>{$content}</div>";
+            $result['display'] = "<div class='{$cached['area']}' data-para='{$para}'>{$inner}</div>";
+        } else {
+            // 其他格式一行一句
+            $result['display'] = implode("\n", $cached['display']);
+        }
+
+        return $result;
+    }
+
+    /**
+     * 段落是章节标题时返回标题级别,否则 0
+     */
+    public function paragraphLevel(int $book, int $para): int
+    {
+        $level = PaliText::where('book', $book)
+            ->where('paragraph', $para)
+            ->where('level', '<', 8)
+            ->value('level');
+
+        return $level ? (int) $level : 0;
+    }
+
+    /**
+     * 段落阅读模式缓存的 key
+     */
+    public static function paragraphCacheKey(int $book, int $para, string $channelUid, string $format): string
+    {
+        return "/read-para/{$book}-{$para}/{$channelUid}/{$format}";
+    }
+
+    /**
+     * 段落缓存的 tag。一个段落一个 channel 的全部格式共用一个 tag
+     */
+    public static function paragraphCacheTag(int $book, int $para, string $channelUid): string
+    {
+        return "read-para:{$book}-{$para}:{$channelUid}";
+    }
+
+    /**
+     * 删除某个段落的阅读模式缓存。句子有增改删时调用,下次 readParagraph 自动重建。
+     */
+    public static function forgetParagraph(int $book, int $para, string $channelUid): void
+    {
+        Cache::tags([self::paragraphCacheTag($book, $para, $channelUid)])->flush();
+    }
+
+    /**
+     * 渲染段落里的句子。不含段落外壳,可直接缓存。
+     *
+     * @return array{area: string, display: array<int, string>, sentences: array<int, array{sid: string, html: string}>}
+     */
+    protected function renderReadSentences(int $book, int $para, string $channelUid, string $format): array
+    {
+        $result = ['area' => 'translation', 'display' => [], 'sentences' => []];
+        $channel = Channel::where('uid', $channelUid)
+            ->select(['uid', 'type', 'lang', 'name'])->first();
+        if (! $channel) {
+            return $result;
+        }
+        $isOrigin = $channel->type === 'original' || $channel->type === 'wbw';
+        $channelType = $channel->type === 'nissaya' ? 'nissaya' : 'translation';
+        $result['area'] = $isOrigin ? 'original' : 'translation';
+
+        $records = Sentence::select($this->selectCol)
+            ->where('book_id', $book)
+            ->where('paragraph', $para)
+            ->where('channel_uid', $channelUid)
+            ->orderBy('word_start')
+            ->get();
+
+        foreach ($records as $row) {
+            $html = MdRender::render(
+                $row->content,
+                [$row->channel_uid],
+                null,
+                'read',
+                $channelType,
+                $row->content_type,
+                $format
+            );
+            if (empty($html)) {
+                continue;
+            }
+            $sid = "{$row->book_id}-{$row->paragraph}-{$row->word_start}-{$row->word_end}";
+            $result['sentences'][] = ['sid' => $sid, 'html' => $html];
+            if ($format === 'html') {
+                $class = $isOrigin ? 'sentence origin' : 'sentence';
+                $result['display'][] = "<div class='{$class}' data-sid='{$sid}'>{$html}</div>";
+            } else {
+                $result['display'][] = $html;
+            }
+        }
+
+        return $result;
+    }
 }

+ 2 - 0
api-v13/routes/api.php

@@ -111,6 +111,7 @@ use App\Http\Controllers\TermExportController;
 use App\Http\Controllers\TermSummaryController;
 use App\Http\Controllers\TermVocabularyController;
 use App\Http\Controllers\TipitakaContentController;
+use App\Http\Controllers\TipitakaContentParaController;
 use App\Http\Controllers\TransferController;
 use App\Http\Controllers\UpdatePaliSynonymsController;
 use App\Http\Controllers\UpgradeController;
@@ -319,6 +320,7 @@ Route::group([
     Route::apiResource('paragraph-content', ParagraphContentController::class);
     Route::apiResource('heartbeat', HeartbeatController::class);
     Route::apiResource('tipitaka-content', TipitakaContentController::class);
+    Route::apiResource('tipitaka-content-para', TipitakaContentParaController::class);
 
     Route::post('mock/openai/chat/completions', [MockOpenAIController::class, 'chatCompletions']);
     Route::post('mock/openai/completions', [MockOpenAIController::class, 'completions']);

+ 166 - 0
api-v13/tests/Feature/TipitakaContentParaTest.php

@@ -0,0 +1,166 @@
+<?php
+
+use App\Models\PaliText;
+use App\Models\Sentence;
+use App\Services\PaliContentService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 建一个句子,返回模型
+ */
+function makeSentence(string $channelUid, int $book, int $para, int $wordStart, string $content): Sentence
+{
+    $sentence = new Sentence;
+    $sentence->forceFill([
+        // sentences.id 不是自增列,必须显式给值
+        'id' => random_int(1, PHP_INT_MAX),
+        'uid' => (string) Str::uuid(),
+        'book_id' => $book,
+        'paragraph' => $para,
+        'word_start' => $wordStart,
+        'word_end' => $wordStart,
+        'channel_uid' => $channelUid,
+        'editor_uid' => (string) Str::uuid(),
+        'content' => $content,
+        'content_type' => 'markdown',
+        'strlen' => mb_strlen($content),
+        'status' => 30,
+        'create_time' => time() * 1000,
+        'modify_time' => time() * 1000,
+        'language' => 'zh-Hans',
+    ])->save();
+
+    return $sentence;
+}
+
+/**
+ * 建测试用的 channel 和句子:book 9001 的第 1 段两句,第 2 段一句。返回 channel uid
+ */
+function makeParagraphFixture(): string
+{
+    $channel = makeChannel(makeStudio('para-owner'), 'para channel');
+    makeSentence($channel, 9001, 1, 1, 'first sentence');
+    makeSentence($channel, 9001, 1, 2, 'second sentence');
+    makeSentence($channel, 9001, 2, 1, 'other paragraph');
+
+    return $channel;
+}
+
+it('renders every sentence of a paragraph wrapped in divs', function () {
+    $channel = makeParagraphFixture();
+    $data = $this->getJson("/api/v2/tipitaka-content-para/9001-1?channel={$channel}")
+        ->assertOk()
+        ->json('data');
+
+    expect($data['para'])->toBe(1);
+    // 默认只输出 display
+    expect($data)->not->toHaveKey('sentences');
+    expect($data['display'])
+        ->toContain("<div class='translation' data-para='1'>")
+        ->toContain("<div class='sentence' data-sid='9001-1-1-1'>")
+        ->toContain("<div class='sentence' data-sid='9001-1-2-2'>")
+        ->toContain("<div class='para-block'>");
+});
+
+it('wraps a chapter title paragraph in a heading', function () {
+    $channel = makeParagraphFixture();
+    (new PaliText)->forceFill([
+        'book' => 9001,
+        'paragraph' => 1,
+        'level' => 2,
+        'class' => '',
+        'toc' => '',
+        'text' => '',
+        'html' => '',
+        'pcd_book_id' => 0,
+        'uid' => (string) Str::uuid(),
+    ])->save();
+
+    $display = $this->getJson("/api/v2/tipitaka-content-para/9001-1?channel={$channel}")
+        ->assertOk()
+        ->json('data.display');
+
+    expect($display)->toContain('<h2>')->not->toContain('para-block');
+});
+
+it('can output only the sentences or both', function () {
+    $channel = makeParagraphFixture();
+    $sentences = $this->getJson("/api/v2/tipitaka-content-para/9001-1?channel={$channel}&view=sentences")
+        ->assertOk()
+        ->json('data');
+    expect($sentences)->not->toHaveKey('display');
+    expect($sentences['sentences'])->toHaveCount(2);
+    expect($sentences['sentences'][0])->toHaveKeys(['sid', 'html']);
+
+    $all = $this->getJson("/api/v2/tipitaka-content-para/9001-1?channel={$channel}&view=all")
+        ->assertOk()
+        ->json('data');
+    expect($all)->toHaveKeys(['para', 'display', 'sentences']);
+
+    $items = $this->getJson("/api/v2/tipitaka-content-para?book=9001&para=1&channel={$channel}&view=sentences")
+        ->assertOk()
+        ->json('data.items');
+    expect($items[0])->not->toHaveKey('display');
+    expect($items[0]['sentences'])->toHaveCount(2);
+});
+
+it('lists the paragraphs of a range and skips empty ones', function () {
+    $channel = makeParagraphFixture();
+    $items = $this->getJson("/api/v2/tipitaka-content-para?book=9001&para=1&to=3&channel={$channel}")
+        ->assertOk()
+        ->json('data.items');
+
+    expect(array_column($items, 'para'))->toBe([1, 2]);
+});
+
+it('outputs one line per sentence for non html formats', function () {
+    $channel = makeParagraphFixture();
+    $display = $this->getJson("/api/v2/tipitaka-content-para/9001-1?channel={$channel}&format=text")
+        ->assertOk()
+        ->json('data.display');
+
+    expect($display)->toBe("first sentence\nsecond sentence");
+});
+
+it('rejects an invalid id or channel', function () {
+    $channel = makeParagraphFixture();
+    $this->getJson("/api/v2/tipitaka-content-para/bad-id?channel={$channel}")
+        ->assertJsonPath('ok', false);
+    $this->getJson('/api/v2/tipitaka-content-para/9001-1?channel=not-a-uuid')
+        ->assertJsonPath('ok', false);
+    $this->getJson("/api/v2/tipitaka-content-para/9001-9?channel={$channel}")
+        ->assertJsonPath('ok', false);
+});
+
+it('caches the paragraph and drops the cache when a sentence changes', function () {
+    $channel = makeParagraphFixture();
+    $url = "/api/v2/tipitaka-content-para/9001-1?channel={$channel}";
+    $this->getJson($url)->assertOk();
+
+    $tag = PaliContentService::paragraphCacheTag(9001, 1, $channel);
+    $key = PaliContentService::paragraphCacheKey(9001, 1, $channel, 'html');
+    expect(Cache::tags([$tag])->has($key))->toBeTrue();
+
+    $sentence = Sentence::where('book_id', 9001)->where('paragraph', 1)->orderBy('word_start')->first();
+    $sentence->content = 'changed sentence';
+    $sentence->save();
+
+    expect(Cache::tags([$tag])->has($key))->toBeFalse();
+    expect($this->getJson($url)->json('data.display'))->toContain('changed sentence');
+});
+
+it('drops the cache when a sentence is added or deleted', function () {
+    $channel = makeParagraphFixture();
+    $url = "/api/v2/tipitaka-content-para/9001-1?channel={$channel}";
+    $this->getJson($url)->assertOk();
+
+    makeSentence($channel, 9001, 1, 3, 'third sentence');
+    expect($this->getJson($url.'&view=sentences')->json('data.sentences'))->toHaveCount(3);
+
+    Sentence::where('book_id', 9001)->where('paragraph', 1)->where('word_start', 3)->first()->delete();
+    expect($this->getJson($url.'&view=sentences')->json('data.sentences'))->toHaveCount(2);
+});