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

Merge pull request #2469 from visuddhinanda/development

Development
visuddhinanda 2 дней назад
Родитель
Сommit
7e3fda1ce8

+ 22 - 3
api-v13/app/Http/Controllers/DiscussionController.php

@@ -305,18 +305,25 @@ class DiscussionController extends Controller
         // validate
         // read more on validation at http://laravel.com/docs/validation
 
+        $annotationRules = [
+            'pos_start' => 'nullable|integer|min:0',
+            'pos_end' => 'nullable|integer|min:0',
+            'quote_exact' => 'nullable|string',
+            'quote_prefix' => 'nullable|string',
+            'quote_suffix' => 'nullable|string',
+        ];
         if ($request->has('parent')) {
-            $rules = [];
+            $rules = $annotationRules;
             $parentInfo = Discussion::find($request->input('parent'));
             if (! $parentInfo) {
                 return $this->error('no record');
             }
         } else {
-            $rules = [
+            $rules = array_merge([
                 'res_id' => 'required',
                 'res_type' => 'required',
                 'title' => 'required',
-            ];
+            ], $annotationRules);
         }
 
         $validated = $request->validate($rules);
@@ -336,6 +343,11 @@ class DiscussionController extends Controller
         $discussion->content_type = $request->input('content_type', 'markdown');
         $discussion->parent = $request->input('parent', null);
         $discussion->editor_uid = $user['user_uid'];
+        $discussion->pos_start = $request->input('pos_start');
+        $discussion->pos_end = $request->input('pos_end');
+        $discussion->quote_exact = $request->input('quote_exact');
+        $discussion->quote_prefix = $request->input('quote_prefix');
+        $discussion->quote_suffix = $request->input('quote_suffix');
         $discussion->save();
         // 更新parent children_count
         if ($request->has('parent')) {
@@ -453,6 +465,13 @@ class DiscussionController extends Controller
         if ($request->has('type')) {
             $discussion->type = $request->input('type');
         }
+        // 注释锚点字段:增量更新,只改请求里出现的字段,好让前端能显式清空;
+        // 未提交的字段必须原样保留。
+        foreach (['pos_start', 'pos_end', 'quote_exact', 'quote_prefix', 'quote_suffix'] as $field) {
+            if ($request->has($field)) {
+                $discussion->{$field} = $request->input($field);
+            }
+        }
         // $discussion->editor_uid = $user['user_uid'];
         $discussion->save();
 

+ 5 - 0
api-v13/app/Http/Resources/DiscussionResource.php

@@ -37,6 +37,11 @@ class DiscussionResource extends JsonResource
             'children_count' => Discussion::where('parent', $this->id)->count(),
             'created_at' => $this->created_at,
             'updated_at' => $this->updated_at,
+            'pos_start' => $this->pos_start,
+            'pos_end' => $this->pos_end,
+            'quote_exact' => $this->quote_exact,
+            'quote_prefix' => $this->quote_prefix,
+            'quote_suffix' => $this->quote_suffix,
         ];
         $channels = [];
         switch ($this->res_type) {

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

@@ -2,6 +2,7 @@
 
 namespace App\Models;
 
+use App\Services\PaliContentService;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 
@@ -13,6 +14,8 @@ class Discussion extends Model
 
     protected $casts = [
         'id' => 'string',
+        'pos_start' => 'integer',
+        'pos_end' => 'integer',
     ];
 
     // 批量填充
@@ -26,8 +29,43 @@ class Discussion extends Model
         'content_type',
         'parent',
         'editor_uid',
+        'pos_start',
+        'pos_end',
+        'quote_exact',
+        'quote_prefix',
+        '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 = [
         'content_type' => 'markdown',

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

@@ -734,7 +734,6 @@ class PaliContentService
         $notes = Discussion::where('res_type', 'sentence')
             ->where('res_id', $row->uid)
             ->where('type', 'note')
-            ->orderByDesc('pos_end')
             ->get();
 
         if ($notes->isEmpty()) {
@@ -743,22 +742,43 @@ class PaliContentService
 
         $sid = "{$row->book_id}-{$row->paragraph}-{$row->word_start}-{$row->word_end}";
         $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 = [];
         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;
             }
-            // 义注实际内容:先用 MdRender 渲染义注句子模板({{book-para-start-end}})得到,
+            preg_match_all('/\{\{(\d+)-(\d+)-(\d+)-(\d+)\}\}/', $noteContent, $sents, PREG_SET_ORDER);
+            // 义注实际内容:先用 MdRender 渲染义注句子模板得到,
             // 再放进 {{note|text=…}} —— 直接嵌套 {{…}} 会被 wiki2xml 的平铺替换破坏。
             // 用 text 格式渲染义注内容:避免「1.」被 markdown 解释成有序列表,
             // 产生 <ol></p></p> 这类坏 HTML 把 sidenote 的闭合结构破坏、吞掉后续正文。
-            // 义注正文只需译文(不要巴利原文):把裸句模板 {{book-para-start-end}}
+            // 义注正文只需译文(不要巴利原文):把每个裸句模板 {{book-para-start-end}}
             // 转成 {{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(
                 $noteTpl,
                 [$row->channel_uid],
@@ -777,16 +797,13 @@ class PaliContentService
             // 用 {{note}} 模板渲染 tufte sidenote(label + input + span.sidenote),
             // 复用 render_note() 的结构,不再手拼 sidenote HTML。
             // 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')
                 .$noteTplInline
                 .mb_substr($content, $pos, null, 'UTF-8');

+ 20 - 14
api-v13/phpunit.xml

@@ -17,25 +17,31 @@
             <directory>app</directory>
         </include>
     </source>
+    <!--
+        每个 env 都必须带 force="true":php artisan test 会先 bootstrap Laravel 加载 .env,
+        此时 .env 的值已被 putenv 到真实环境。PHPUnit 12 的 <env> 默认 force=false,
+        遇到已存在的环境变量会跳过,导致这里的值全部被 .env 抢占(测试实际连到了
+        开发库 wikipali 而非 mint_test)。
+    -->
     <php>
-        <env name="APP_ENV" value="testing"/>
-        <env name="APP_MAINTENANCE_DRIVER" value="file"/>
-        <env name="BCRYPT_ROUNDS" value="4"/>
-        <env name="BROADCAST_CONNECTION" value="null"/>
-        <env name="CACHE_STORE" value="array"/>
+        <env name="APP_ENV" value="testing" force="true"/>
+        <env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
+        <env name="BCRYPT_ROUNDS" value="4" force="true"/>
+        <env name="BROADCAST_CONNECTION" value="null" force="true"/>
+        <env name="CACHE_STORE" value="array" force="true"/>
         <!--
             迁移文件含 Postgres 专有语句(CREATE EXTENSION "uuid-ossp" 等),无法在 sqlite 上跑,
             故测试也走 pgsql。DB_DATABASE 必须固定为独立的测试库:RefreshDatabase 会清空目标库,
             指向开发库会直接毁掉数据。
         -->
-        <env name="DB_CONNECTION" value="pgsql"/>
-        <env name="DB_DATABASE" value="mint_test"/>
-        <env name="DB_URL" value=""/>
-        <env name="MAIL_MAILER" value="array"/>
-        <env name="QUEUE_CONNECTION" value="sync"/>
-        <env name="SESSION_DRIVER" value="array"/>
-        <env name="PULSE_ENABLED" value="false"/>
-        <env name="TELESCOPE_ENABLED" value="false"/>
-        <env name="NIGHTWATCH_ENABLED" value="false"/>
+        <env name="DB_CONNECTION" value="pgsql" force="true"/>
+        <env name="DB_DATABASE" value="mint_test" force="true"/>
+        <env name="DB_URL" value="" force="true"/>
+        <env name="MAIL_MAILER" value="array" force="true"/>
+        <env name="QUEUE_CONNECTION" value="sync" force="true"/>
+        <env name="SESSION_DRIVER" value="array" force="true"/>
+        <env name="PULSE_ENABLED" value="false" force="true"/>
+        <env name="TELESCOPE_ENABLED" value="false" force="true"/>
+        <env name="NIGHTWATCH_ENABLED" value="false" force="true"/>
     </php>
 </phpunit>

+ 128 - 0
api-v13/tests/Feature/DiscussionControllerTest.php

@@ -0,0 +1,128 @@
+<?php
+
+use App\Models\Discussion;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+it('stores the annotation selector fields on create', function () {
+    $userUid = makeStudio('annotator');
+    $resId = (string) Str::uuid();
+
+    $response = $this->postJson('/api/v2/discussion', [
+        'res_id' => $resId,
+        'res_type' => 'sentence',
+        'type' => 'note',
+        'title' => '义注',
+        'content' => '{{1-2-3-4}}',
+        'pos_start' => 10,
+        'pos_end' => 25,
+        'quote_exact' => '被锚定文本',
+        'quote_prefix' => '前缀上下文',
+        'quote_suffix' => '后缀上下文',
+        'notification' => false,
+    ], authHeader($userUid))->assertOk();
+
+    $saved = Discussion::where('editor_uid', $userUid)->first();
+
+    expect($saved->pos_start)->toBe(10);
+    expect($saved->pos_end)->toBe(25);
+    expect($saved->quote_exact)->toBe('被锚定文本');
+    expect($saved->quote_prefix)->toBe('前缀上下文');
+    expect($saved->quote_suffix)->toBe('后缀上下文');
+
+    // 资源输出同样暴露这些字段
+    expect($response->json('data.pos_start'))->toBe(10);
+    expect($response->json('data.pos_end'))->toBe(25);
+    expect($response->json('data.quote_exact'))->toBe('被锚定文本');
+    expect($response->json('data.quote_prefix'))->toBe('前缀上下文');
+    expect($response->json('data.quote_suffix'))->toBe('后缀上下文');
+});
+
+it('leaves annotation selector fields null when not provided', function () {
+    $userUid = makeStudio('annotator');
+
+    $this->postJson('/api/v2/discussion', [
+        'res_id' => (string) Str::uuid(),
+        'res_type' => 'sentence',
+        'type' => 'note',
+        'title' => '无锚点',
+        'content' => '{{1-2-3-4}}',
+        'notification' => false,
+    ], authHeader($userUid))->assertOk();
+
+    $saved = Discussion::where('editor_uid', $userUid)->first();
+
+    expect($saved->pos_start)->toBeNull();
+    expect($saved->pos_end)->toBeNull();
+    expect($saved->quote_exact)->toBeNull();
+    expect($saved->quote_prefix)->toBeNull();
+    expect($saved->quote_suffix)->toBeNull();
+});
+
+it('rejects an invalid annotation selector', function () {
+    $userUid = makeStudio('annotator');
+
+    $this->postJson('/api/v2/discussion', [
+        'res_id' => (string) Str::uuid(),
+        'res_type' => 'sentence',
+        'title' => 'x',
+        'pos_start' => -1,
+        'notification' => false,
+    ], authHeader($userUid))->assertStatus(422);
+
+    expect(Discussion::count())->toBe(0);
+});
+
+it('updates only the annotation fields present in the request', function () {
+    $userUid = makeStudio('annotator');
+    $discussion = new Discussion;
+    $discussion->forceFill([
+        'res_id' => (string) Str::uuid(),
+        'res_type' => 'sentence',
+        'type' => 'note',
+        'title' => '旧标题',
+        'content' => '{{1-2-3-4}}',
+        'editor_uid' => $userUid,
+        'pos_start' => 1,
+        'pos_end' => 2,
+        'quote_exact' => '保留我',
+        'quote_prefix' => '保留前缀',
+        'quote_suffix' => '保留后缀',
+    ])->save();
+
+    $this->putJson("/api/v2/discussion/{$discussion->id}", [
+        'pos_start' => 5,
+        'quote_exact' => '新摘录',
+    ], authHeader($userUid))->assertOk();
+
+    $discussion->refresh();
+
+    // 只改提交的字段
+    expect($discussion->pos_start)->toBe(5);
+    expect($discussion->quote_exact)->toBe('新摘录');
+    // 未提交的字段原样保留
+    expect($discussion->pos_end)->toBe(2);
+    expect($discussion->quote_prefix)->toBe('保留前缀');
+    expect($discussion->quote_suffix)->toBe('保留后缀');
+});
+
+it('allows clearing an annotation field explicitly', function () {
+    $userUid = makeStudio('annotator');
+    $discussion = new Discussion;
+    $discussion->forceFill([
+        'res_id' => (string) Str::uuid(),
+        'res_type' => 'sentence',
+        'type' => 'note',
+        'title' => '旧标题',
+        'editor_uid' => $userUid,
+        'quote_exact' => '旧摘录',
+    ])->save();
+
+    $this->putJson("/api/v2/discussion/{$discussion->id}", [
+        'quote_exact' => null,
+    ], authHeader($userUid))->assertOk();
+
+    expect($discussion->refresh()->quote_exact)->toBeNull();
+});

+ 38 - 1
api-v13/tests/TestCase.php

@@ -6,5 +6,42 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
 
 abstract class TestCase extends BaseTestCase
 {
-    //
+    /**
+     * 测试环境变量:与 phpunit.xml 的 <env> 保持一致。
+     *
+     * 必须在这里(Laravel bootstrap 之前)把这些值同时写入 putenv / $_ENV / $_SERVER。
+     * 原因:Laravel 的 Env::get() 通过 Dotenv 的多 adapter 仓库读取,优先级是
+     * $_SERVER > $_ENV > getenv;而 PHPUnit 的 <env> 只写 putenv 与 $_ENV、不写 $_SERVER,
+     * 导致 .env 里的 DB_DATABASE(wikipali)在 $_SERVER 里胜出,测试会连到开发库,
+     * RefreshDatabase 的 migrate:fresh 会直接清空开发库数据。
+     *
+     * @var array<string, string>
+     */
+    private const TEST_ENV = [
+        'APP_ENV' => 'testing',
+        'APP_MAINTENANCE_DRIVER' => 'file',
+        'BCRYPT_ROUNDS' => '4',
+        'BROADCAST_CONNECTION' => 'null',
+        'CACHE_STORE' => 'array',
+        'DB_CONNECTION' => 'pgsql',
+        'DB_DATABASE' => 'mint_test',
+        'DB_URL' => '',
+        'MAIL_MAILER' => 'array',
+        'QUEUE_CONNECTION' => 'sync',
+        'SESSION_DRIVER' => 'array',
+        'PULSE_ENABLED' => 'false',
+        'TELESCOPE_ENABLED' => 'false',
+        'NIGHTWATCH_ENABLED' => 'false',
+    ];
+
+    public function createApplication()
+    {
+        foreach (self::TEST_ENV as $key => $value) {
+            putenv("{$key}={$value}");
+            $_ENV[$key] = $value;
+            $_SERVER[$key] = $value;
+        }
+
+        return parent::createApplication();
+    }
 }