Sfoglia il codice sorgente

Merge pull request #2465 from visuddhinanda/development

Development
visuddhinanda 5 giorni fa
parent
commit
963cdb014d

+ 6 - 1
api-v13/app/Http/Controllers/Library/BookController.php

@@ -103,6 +103,10 @@ class BookController extends Controller
     {
 
         $channelId = $request->input('channel');
+        $isDefaultChannel = empty($channelId);
+        if ($isDefaultChannel) {
+            $channelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
+        }
 
         [$bookId, $paraId] = explode('-', $id);
         $bookId = (int) $bookId;
@@ -132,6 +136,7 @@ class BookController extends Controller
         $book['categories'] = $chapter['category'];
         $book['title'] = $chapter['title'];
         $book['author'] = $channel['name'];
+        $book['is_original'] = in_array($channel['type'], ['original', 'wbw'], true);
         $book['studio'] = $studio;
         $book['tags'] = [];
         $book['book_title'] = $this->getBookTitle($bookId, $paraId, $channelId);
@@ -154,7 +159,7 @@ class BookController extends Controller
         $editor_link = config('mint.server.dashboard_base_path')
             ."/workspace/tipitaka/chapter/{$id}?channel={$channelId}";
 
-        $view = view('library.book.read', compact('book', 'channels', 'editor_link', 'commentaryChannels'));
+        $view = view('library.book.read', compact('book', 'channels', 'editor_link', 'commentaryChannels', 'isDefaultChannel'));
 
         return $view;
     }

+ 23 - 0
api-v13/app/Http/Resources/SearchPaliWbwResource.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Resources;
 
+use App\Models\PageNumber;
 use App\Models\PaliText;
 use Illuminate\Contracts\Support\Arrayable;
 use Illuminate\Http\Request;
@@ -31,10 +32,16 @@ class SearchPaliWbwResource extends JsonResource
             $data['path'] = json_decode($paliText->path);
             if ($paliText->level < 100) {
                 $data['paliTitle'] = $paliText->toc;
+                $book = $this->book;
+                $para = $this->paragraph;
+                $data['link'] = config('app.url')."/library/tipitaka/{$book}-{$para}/read";
             } else {
                 $data['paliTitle'] = PaliText::where('book', $this->book)
                     ->where('paragraph', $paliText->parent)
                     ->value('toc');
+                $book = end($data['path'])['book'];
+                $para = end($data['path'])['paragraph'];
+                $data['link'] = config('app.url')."/library/tipitaka/{$book}-{$para}/read#{$this->paragraph}";
             }
             $keyWords = explode(',', $request->input('key'));
             $keyWordsUpper = $keyWords;
@@ -55,6 +62,22 @@ class SearchPaliWbwResource extends JsonResource
             $data['highlight'] = str_replace($keyWordsUpper, $keyReplace, $paliText->html);
         }
 
+        $pageNumbers = PageNumber::where('book', $this->book)
+            ->where('paragraph', $this->paragraph)
+            ->orderBy('wid')
+            ->get()
+            ->unique('type')
+            ->map(fn ($pageNumber) => [
+                'type' => $pageNumber->type,
+                'page' => $pageNumber->page,
+            ])
+            ->values()
+            ->all();
+
+        if ($pageNumbers !== []) {
+            $data['ref'] = $pageNumbers;
+        }
+
         return $data;
     }
 }

+ 20 - 26
api-v13/app/Services/OpenSearchService.php

@@ -985,26 +985,27 @@ class OpenSearchService
 
         $cacheKey = 'embedding:' . md5($text);
 
-        return Cache::remember($cacheKey, now()->addDays(7), function () use ($text) {
-            $response = $this->http->post('embeddings', [
-                'headers' => [
-                    'Authorization' => 'Bearer ' . $this->openaiApiKey,
-                    'Content-Type' => 'application/json',
-                ],
-                'json' => [
-                    'model' => 'text-embedding-3-small',
-                    'input' => $text,
-                ],
-            ]);
+        return Cache::tags(['embedding'])
+            ->remember($cacheKey, now()->addDays(7), function () use ($text) {
+                $response = $this->http->post('embeddings', [
+                    'headers' => [
+                        'Authorization' => 'Bearer ' . $this->openaiApiKey,
+                        'Content-Type' => 'application/json',
+                    ],
+                    'json' => [
+                        'model' => 'text-embedding-3-small',
+                        'input' => $text,
+                    ],
+                ]);
 
-            $json = json_decode((string) $response->getBody(), true);
+                $json = json_decode((string) $response->getBody(), true);
 
-            if (empty($json['data'][0]['embedding'])) {
-                throw new Exception('OpenAI embedding 返回异常: ' . json_encode($json));
-            }
+                if (empty($json['data'][0]['embedding'])) {
+                    throw new Exception('OpenAI embedding 返回异常: ' . json_encode($json));
+                }
 
-            return $json['data'][0]['embedding'];
-        });
+                return $json['data'][0]['embedding'];
+            });
     }
 
     /**
@@ -1034,16 +1035,9 @@ class OpenSearchService
      *   $count = $service->clearAllEmbeddingCache();
      *   echo "已清理缓存 {$count} 条";
      */
-    public function clearAllEmbeddingCache(): int
+    public function clearAllEmbeddingCache(): void
     {
-        $redis = Cache::getRedis();
-        $keys = $redis->keys('embedding:*');
-
-        if (! empty($keys)) {
-            $redis->del($keys);
-        }
-
-        return count($keys);
+        Cache::tags(['embedding'])->flush();
     }
 
     /**

+ 17 - 13
api-v13/app/Services/PaliContentService.php

@@ -655,10 +655,13 @@ class PaliContentService
         $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) {
+        $cached = Cache::remember(
+            $key,
+            config('mint.cache.expire'),
+            function () use ($book, $para, $channelUid, $format) {
                 return $this->renderReadSentences($book, $para, $channelUid, $format);
-            });
+            }
+        );
 
         $result = [
             'para' => $para,
@@ -673,7 +676,7 @@ class PaliContentService
             // 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>";
+            $result['display'] = "<div id='para-{$para}' class='{$cached['area']}' data-para='{$para}'>{$inner}</div>";
         } else {
             // 其他格式一行一句
             $result['display'] = implode("\n", $cached['display']);
@@ -696,19 +699,18 @@ class PaliContentService
     }
 
     /**
-     * 段落阅读模式缓存的 key
+     * 阅读模式支持的全部格式。forgetParagraph 需要逐格式清除缓存 key。
+     *
+     * @var array<int, string>
      */
-    public static function paragraphCacheKey(int $book, int $para, string $channelUid, string $format): string
-    {
-        return "/read-para/{$book}-{$para}/{$channelUid}/{$format}";
-    }
+    private const FORMATS = ['html', 'markdown', 'react', 'text'];
 
     /**
-     * 段落缓存的 tag。一个段落一个 channel 的全部格式共用一个 tag
+     * 段落阅读模式缓存的 key
      */
-    public static function paragraphCacheTag(int $book, int $para, string $channelUid): string
+    public static function paragraphCacheKey(int $book, int $para, string $channelUid, string $format): string
     {
-        return "read-para:{$book}-{$para}:{$channelUid}";
+        return "/read-para/{$book}-{$para}/{$channelUid}/{$format}";
     }
 
     /**
@@ -716,7 +718,9 @@ class PaliContentService
      */
     public static function forgetParagraph(int $book, int $para, string $channelUid): void
     {
-        Cache::tags([self::paragraphCacheTag($book, $para, $channelUid)])->flush();
+        foreach (self::FORMATS as $format) {
+            Cache::forget(self::paragraphCacheKey($book, $para, $channelUid, $format));
+        }
     }
 
     /**

+ 2 - 2
api-v13/resources/css/modules/reader-content.css

@@ -18,8 +18,8 @@ article.reader-body {
 article.reader-body div.sentence {
     display: inline;
 }
-article.reader-body div.translation .para-block {
-    margin-bottom: 0.75em;
+article.reader-body .para-block {
+    margin-bottom: 1em;
 }
 /* ── 段落 ── */
 article.reader-body p {

+ 15 - 0
api-v13/resources/css/modules/reader.css

@@ -349,3 +349,18 @@ body {
     color: var(--tblr-secondary);
     display: block;
 }
+
+/* ══════════════════════════════════════════
+   九、锚点段落高亮(hash 跳转定位)
+   柔和琥珀色,浅色/暗色模式各一套,避免过亮
+   ══════════════════════════════════════════ */
+
+.reader-para-anchor {
+    background-color: rgba(255, 213, 79, 0.18);
+    border-radius: 6px;
+    transition: background-color 0.3s ease;
+}
+
+.dark-mode .reader-para-anchor {
+    background-color: rgba(255, 213, 79, 0.14);
+}

+ 38 - 0
api-v13/resources/js/modules/reader.js

@@ -4,6 +4,44 @@ export function initReader() {
     injectCommentaryMarkers();
     injectEvaluateMarkers();
     initTocToggle();
+    initHashScroll();
+}
+
+// 处理 URL hash(#354 / #94-354 / #para-354):滚动到对应段落并加高亮底色
+function initHashScroll() {
+    const scrollToParagraph = (paragraph, smooth = false) => {
+        const el = document.getElementById(`para-${paragraph}`);
+        if (!el) {
+            return;
+        }
+
+        document
+            .querySelectorAll('.reader-para-anchor')
+            .forEach((node) => node.classList.remove('reader-para-anchor'));
+        el.classList.add('reader-para-anchor');
+        el.scrollIntoView({ block: 'start', behavior: smooth ? 'smooth' : 'auto' });
+    };
+
+    // 统一解析为段落号:取最后一个 '-' 之后的数字,兼容 #354 与 #94-354
+    const parseHash = (hash) => {
+        if (!hash || hash === '#') {
+            return null;
+        }
+        const paragraph = parseInt(hash.replace(/^#/, '').split('-').pop(), 10);
+        return Number.isInteger(paragraph) && paragraph > 0 ? paragraph : null;
+    };
+
+    window.addEventListener('hashchange', () => {
+        const paragraph = parseHash(window.location.hash);
+        if (paragraph !== null) {
+            scrollToParagraph(paragraph, true);
+        }
+    });
+
+    const paragraph = parseHash(window.location.hash);
+    if (paragraph !== null) {
+        scrollToParagraph(paragraph);
+    }
 }
 
 // TOC 折叠/展开:点击按钮切换所在 .toc-tree 的 .toc-expanded 类

+ 1 - 0
api-v13/resources/lang/en/library.php

@@ -114,6 +114,7 @@ return [
     'versions' => 'Versions',
     'logout' => 'Logout',
     'select_version' => 'Select Version',
+    'default_version_notice' => 'No version selected — showing the Pali original by default.',
     'no_content' => 'No content',
     'prev_article' => 'Previous',
     'next_article' => 'Next',

+ 1 - 0
api-v13/resources/lang/zh-Hans/library.php

@@ -114,6 +114,7 @@ return [
     'versions' => '版本',
     'logout' => '退出',
     'select_version' => '选择版本',
+    'default_version_notice' => '未选择版本,默认展示巴利原文。',
     'no_content' => '没有内容',
     'prev_article' => '上一篇',
     'next_article' => '下一篇',

+ 1 - 0
api-v13/resources/lang/zh-Hant/library.php

@@ -114,6 +114,7 @@ return [
     'versions' => '版本',
     'logout' => '登出',
     'select_version' => '選擇版本',
+    'default_version_notice' => '未選擇版本,預設顯示巴利原文。',
     'no_content' => '無內容',
     'prev_article' => '上一篇',
     'next_article' => '下一篇',

+ 20 - 1
api-v13/resources/views/library/book/read.blade.php

@@ -170,6 +170,24 @@
                     @endif
                 </p>
 
+                {{-- 未选择版本时的提示条:默认展示巴利原文,并提供版本切换入口 --}}
+                @if(!empty($isDefaultChannel))
+                <div class="alert alert-info d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4" role="alert">
+                    <div class="d-flex align-items-center gap-2">
+                        <i class="ti ti-info-circle" aria-hidden="true"></i>
+                        <span>{{ __('library.default_version_notice') }}</span>
+                    </div>
+                    @if(!empty($channels))
+                    <button type="button"
+                        class="btn btn-info btn-sm"
+                        data-bs-toggle="offcanvas"
+                        data-bs-target="#channelDrawer">
+                        <i class="ti ti-stack-2 me-1" aria-hidden="true"></i>{{ __('library.select_version') }}
+                    </button>
+                    @endif
+                </div>
+                @endif
+
                 {{-- ↓ 正文内容用 article 包裹,隔离排版作用域 ── --}}
                 <article class="reader-body">
                     @if(isset($book['content']))
@@ -361,7 +379,8 @@
     }
 
     document.addEventListener('DOMContentLoaded', function() {
-        const showOrigin = getCookie('show_origin') === 'true';
+        const isOriginalChannel = {{ !empty($book['is_original']) ? 'true' : 'false' }};
+        const showOrigin = isOriginalChannel || getCookie('show_origin') === 'true';
         document.getElementById('showOrigin').checked = showOrigin;
         document.getElementById('uiLanguage').value = getCookie('ui_language') || 'auto';
         document.getElementById('paliScript').value = getCookie('pali_script') || 'auto';

+ 97 - 0
api-v13/tests/Feature/SearchPaliWbwRefTest.php

@@ -0,0 +1,97 @@
+<?php
+
+/**
+ * search-pali-wbw 的资源里要带上 page_numbers 的页码引用。
+ *
+ * 每个 (book, paragraph) 在 page_numbers 里可能因为 wid 不同而有多行,
+ * 输出时每个 type 只保留 wid 最小的那一行,并且只暴露 type / page 两个字段。
+ */
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 造一行能被检索命中的 wbw 词元。
+ */
+function wbwRow(int $paragraph = 1): array
+{
+    return [
+        'book' => 1,
+        'paragraph' => $paragraph,
+        'wid' => 1,
+        'word' => 'dhammo',
+        'real' => 'dhammo',
+        'type' => '',
+        'gramma' => '',
+        'part' => '',
+        'style' => '',
+        'pcd_book_id' => 1,
+        'weight' => 1,
+    ];
+}
+
+/**
+ * 造一段 pali_text,让资源能生成标题 / 链接 / 高亮。
+ */
+function paliTextRow(int $paragraph = 1): array
+{
+    return [
+        'book' => 1,
+        'paragraph' => $paragraph,
+        'level' => 1,
+        'class' => '',
+        'toc' => 'Dhammapada',
+        'text' => '',
+        'html' => '<p>dhammo</p>',
+    ];
+}
+
+/**
+ * 造一行 page_numbers。
+ */
+function pageNumberRow(string $type, int $wid, int $page): array
+{
+    return [
+        'type' => $type,
+        'volume' => 1,
+        'page' => $page,
+        'book' => 1,
+        'paragraph' => 1,
+        'wid' => $wid,
+        'pcd_book_id' => 1,
+    ];
+}
+
+it('adds ref with the smallest wid page for each type', function () {
+    DB::table('wbw_templates')->insert([wbwRow()]);
+    DB::table('pali_texts')->insert([paliTextRow()]);
+    DB::table('page_numbers')->insert([
+        pageNumberRow('a', 5, 111),
+        pageNumberRow('a', 2, 222),
+        pageNumberRow('a', 9, 333),
+        pageNumberRow('b', 4, 444),
+        pageNumberRow('b', 1, 555),
+    ]);
+
+    $response = $this->getJson('/api/v2/search-pali-wbw?key=dhammo')
+        ->assertOk();
+
+    $ref = $response->json('data.rows.0.ref');
+    expect($ref)->toHaveCount(2);
+
+    $byType = collect($ref)->keyBy('type');
+
+    expect($byType->get('a'))->toBe(['type' => 'a', 'page' => 222])
+        ->and($byType->get('b'))->toBe(['type' => 'b', 'page' => 555]);
+});
+
+it('omits ref when there are no page_numbers rows', function () {
+    DB::table('wbw_templates')->insert([wbwRow()]);
+    DB::table('pali_texts')->insert([paliTextRow()]);
+
+    $response = $this->getJson('/api/v2/search-pali-wbw?key=dhammo')
+        ->assertOk();
+
+    expect($response->json('data.rows.0'))->not->toHaveKey('ref');
+});

+ 3 - 4
api-v13/tests/Feature/TipitakaReadParaTest.php

@@ -32,7 +32,7 @@ it('renders every sentence of a paragraph wrapped in divs', function () {
     // 默认只输出 display
     expect($data)->not->toHaveKey('sentences');
     expect($data['display'])
-        ->toContain("<div class='translation' data-para='1'>")
+        ->toContain("<div id='para-1' 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'>");
@@ -113,15 +113,14 @@ it('caches the paragraph and drops the cache when a sentence changes', function
     $url = "/api/v3/tipitaka-read-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();
+    expect(Cache::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(Cache::has($key))->toBeFalse();
     expect($this->getJson($url)->json('data.display'))->toContain('changed sentence');
 });