Jelajahi Sumber

feat(annotation): note 支持多句 content、同位置按义注顺序排序、增改删自动清阅读缓存

- injectAnnotationNotes:content 可并列多个 {{book-para-start-end}},
  逐句取译文合成一个边注,跳转指向第一句;格式不符的记录跳过(不再留下无锚点的脚注)
- 同一插入点的多条注释按义注原文先后排序(不再依赖 pos_end 单键排序的不确定顺序)
- Discussion 模型 saved/deleted 时,若为挂在句子上的 note,清该段 (book, para, channel) 阅读缓存

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 2 hari lalu
induk
melakukan
a1544793f1
2 mengubah file dengan 67 tambahan dan 19 penghapusan
  1. 31 0
      api-v13/app/Models/Discussion.php
  2. 36 19
      api-v13/app/Services/PaliContentService.php

+ 31 - 0
api-v13/app/Models/Discussion.php

@@ -2,6 +2,7 @@
 
 
 namespace App\Models;
 namespace App\Models;
 
 
+use App\Services\PaliContentService;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 
 
@@ -35,6 +36,36 @@ class Discussion extends Model
         'quote_suffix',
         'quote_suffix',
     ];
     ];
 
 
+    /**
+     * 注释记录(type='note')会被注入到所挂句子的阅读页里(PaliContentService::
+     * injectAnnotationNotes),阅读页按 (book, para, channel) 缓存,所以增改删 note
+     * 都要清掉那一段的缓存。改之前是 note、改之后不是(或反过来)的也要清。
+     */
+    protected static function booted(): void
+    {
+        $forget = function (Discussion $discussion) {
+            $wasNote = $discussion->getOriginal('type') === 'note';
+            if ($discussion->type !== 'note' && ! $wasNote) {
+                return;
+            }
+            if ($discussion->res_type !== 'sentence' || empty($discussion->res_id)) {
+                return;
+            }
+            $sentence = Sentence::where('uid', $discussion->res_id)
+                ->first(['book_id', 'paragraph', 'channel_uid']);
+            if (! $sentence) {
+                return;
+            }
+            PaliContentService::forgetParagraph(
+                (int) $sentence->book_id,
+                (int) $sentence->paragraph,
+                (string) $sentence->channel_uid
+            );
+        };
+        static::saved($forget);
+        static::deleted($forget);
+    }
+
     // 设置默认值
     // 设置默认值
     protected $attributes = [
     protected $attributes = [
         'content_type' => 'markdown',
         'content_type' => 'markdown',

+ 36 - 19
api-v13/app/Services/PaliContentService.php

@@ -734,7 +734,6 @@ class PaliContentService
         $notes = Discussion::where('res_type', 'sentence')
         $notes = Discussion::where('res_type', 'sentence')
             ->where('res_id', $row->uid)
             ->where('res_id', $row->uid)
             ->where('type', 'note')
             ->where('type', 'note')
-            ->orderByDesc('pos_end')
             ->get();
             ->get();
 
 
         if ($notes->isEmpty()) {
         if ($notes->isEmpty()) {
@@ -743,22 +742,43 @@ class PaliContentService
 
 
         $sid = "{$row->book_id}-{$row->paragraph}-{$row->word_start}-{$row->word_end}";
         $sid = "{$row->book_id}-{$row->paragraph}-{$row->word_start}-{$row->word_end}";
         $len = mb_strlen($content, 'UTF-8');
         $len = mb_strlen($content, 'UTF-8');
+        // 插入顺序:按插入点倒序(先插靠后的,免得前面的插入改变后面的偏移);
+        // 插入点相同的,按义注原文的先后倒序——同一位置后插入的排在前面,
+        // 倒序插入后读起来就是义注原文顺序。插入点与下面的越界处理口径一致
+        //(null / 越界都算句尾)。义注坐标取 content 第一句的 book-para-start。
+        $notes = $notes->sort(function ($a, $b) use ($len) {
+            $key = function ($note) use ($len) {
+                $pos = $note->pos_end;
+                if ($pos === null || $pos < 0 || $pos > $len) {
+                    $pos = $len;
+                }
+                preg_match('/\{\{(\d+)-(\d+)-(\d+)-\d+\}\}/', (string) $note->content, $m);
+
+                return [$pos, (int) ($m[1] ?? 0), (int) ($m[2] ?? 0), (int) ($m[3] ?? 0)];
+            };
+
+            return $key($b) <=> $key($a);
+        })->values();
         $collected = [];
         $collected = [];
         foreach ($notes as $note) {
         foreach ($notes as $note) {
-            if (empty($note->content)) {
+            // content 是一个或多个义注句子模板 {{book-para-start-end}}(一个词可能由义注
+            // 多句解释,按顺序并列,如 {{135-404-50-54}}{{135-404-55-65}})。
+            // 不是这种格式的记录不认,跳过——不插角标,也不进脚注列表。
+            $noteContent = trim((string) $note->content);
+            if (! preg_match('/^(?:\{\{\d+-\d+-\d+-\d+\}\}\s*)+$/', $noteContent)) {
                 continue;
                 continue;
             }
             }
-            // 义注实际内容:先用 MdRender 渲染义注句子模板({{book-para-start-end}})得到,
+            preg_match_all('/\{\{(\d+)-(\d+)-(\d+)-(\d+)\}\}/', $noteContent, $sents, PREG_SET_ORDER);
+            // 义注实际内容:先用 MdRender 渲染义注句子模板得到,
             // 再放进 {{note|text=…}} —— 直接嵌套 {{…}} 会被 wiki2xml 的平铺替换破坏。
             // 再放进 {{note|text=…}} —— 直接嵌套 {{…}} 会被 wiki2xml 的平铺替换破坏。
             // 用 text 格式渲染义注内容:避免「1.」被 markdown 解释成有序列表,
             // 用 text 格式渲染义注内容:避免「1.」被 markdown 解释成有序列表,
             // 产生 <ol></p></p> 这类坏 HTML 把 sidenote 的闭合结构破坏、吞掉后续正文。
             // 产生 <ol></p></p> 这类坏 HTML 把 sidenote 的闭合结构破坏、吞掉后续正文。
-            // 义注正文只需译文(不要巴利原文):把裸句模板 {{book-para-start-end}}
+            // 义注正文只需译文(不要巴利原文):把每个裸句模板 {{book-para-start-end}}
             // 转成 {{sent|id=…|text=translation}},让 sent 模板只输出 translation。
             // 转成 {{sent|id=…|text=translation}},让 sent 模板只输出 translation。
-            $noteTpl = preg_replace(
-                '/^\{\{(\d+-\d+-\d+-\d+)\}\}$/',
-                '{{sent|id=$1|text=translation}}',
-                trim($note->content)
-            );
+            $noteTpl = implode(' ', array_map(
+                fn ($s) => '{{sent|id='.$s[1].'-'.$s[2].'-'.$s[3].'-'.$s[4].'|text=translation}}',
+                $sents
+            ));
             $noteHtml = MdRender::render(
             $noteHtml = MdRender::render(
                 $noteTpl,
                 $noteTpl,
                 [$row->channel_uid],
                 [$row->channel_uid],
@@ -777,16 +797,13 @@ class PaliContentService
             // 用 {{note}} 模板渲染 tufte sidenote(label + input + span.sidenote),
             // 用 {{note}} 模板渲染 tufte sidenote(label + input + span.sidenote),
             // 复用 render_note() 的结构,不再手拼 sidenote HTML。
             // 复用 render_note() 的结构,不再手拼 sidenote HTML。
             // text 传已预渲染的纯文本译文(嵌套 {{…}} 会被 wiki2xml 平铺替换破坏)。
             // text 传已预渲染的纯文本译文(嵌套 {{…}} 会被 wiki2xml 平铺替换破坏)。
-            $citeHtml = '';
-            $target = '';
-            $noteTplInline = '';
-            if (preg_match('/^\{\{(\d+)-(\d+)-(\d+)-(\d+)\}\}$/', trim($note->content), $m)) {
-                $target = ' data-book="'.$m[1].'" data-para="'.$m[2].'" data-start="'.$m[3].'" data-end="'.$m[4].'"';
-                $citeHtml = '<cite class="anno-jump"'.$target.'>义注</cite>';
-                $noteTplInline = '{{note|text='.$noteHtml
-                    .'|cite=义注'
-                    .'|citelink='.$m[1].'-'.$m[2].'-'.$m[3].'-'.$m[4].'}}';
-            }
+            // 多句时跳转到第一句(义注对这个词的解释从那里开始)。
+            $m = $sents[0];
+            $target = ' data-book="'.$m[1].'" data-para="'.$m[2].'" data-start="'.$m[3].'" data-end="'.$m[4].'"';
+            $citeHtml = '<cite class="anno-jump"'.$target.'>义注</cite>';
+            $noteTplInline = '{{note|text='.$noteHtml
+                .'|cite=义注'
+                .'|citelink='.$m[1].'-'.$m[2].'-'.$m[3].'-'.$m[4].'}}';
             $content = mb_substr($content, 0, $pos, 'UTF-8')
             $content = mb_substr($content, 0, $pos, 'UTF-8')
                 .$noteTplInline
                 .$noteTplInline
                 .mb_substr($content, $pos, null, 'UTF-8');
                 .mb_substr($content, $pos, null, 'UTF-8');