Przeglądaj źródła

Merge pull request #2365 from visuddhinanda/development

Development
visuddhinanda 2 tygodni temu
rodzic
commit
0a8a917c1d

+ 9 - 8
api-v13/app/Console/Commands/IndexTipitaka.php

@@ -288,7 +288,7 @@ class IndexTipitaka extends Command
                     ->orderBy('paragraph', 'desc')->first()
                     ->value('paragraph');
             } else {
-                $end = $chapters[$key + 1]->paragraph;
+                $end = $chapters[$key + 1]->paragraph - 1;
             }
             //获取这个段落之间的全部channel
             $channels = Sentence::where('book_id', $book)
@@ -324,13 +324,14 @@ class IndexTipitaka extends Command
                     $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[] = "<span class='sentence'>{$html}</span>";
-                                    if ($tran['para'] === $start && !empty($curr)) {
-                                        $title = $curr;
+                                    $translation[] = "<div class='sentence' data-sid='{$sid}'>{$html}</div>";
+                                    if ($tran['para'] === $start && !empty($html)) {
+                                        $title = $html;
                                     }
                                 }
                             }
@@ -343,9 +344,9 @@ class IndexTipitaka extends Command
                             foreach ($sent['origin'] as  $origin) {
                                 if ($origin['channel']['id'] === $channel->channel_uid) {
                                     $html = $origin['html'] ?? $origin['content'];
-                                    $original[] = "<span class='sentence origin'>{$html}</span>";
-                                    if (empty($title) && $origin['para'] === $start && !empty($curr)) {
-                                        $title = $curr;
+                                    $original[] = "<div class='sentence origin'  data-sid='{$sid}'>{$html}</div>";
+                                    if (empty($title) && $origin['para'] === $start && !empty($html)) {
+                                        $title = $html;
                                     }
                                 }
                             }
@@ -367,7 +368,7 @@ class IndexTipitaka extends Command
                     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']}'><p>{$htmlContent}</p></div>";
+                        $display[] = "<div class='{$area}' data-para='{$paragraph['para']}'><div class='para-block'>{$htmlContent}</div></div>";
                     }
                 }
                 $this->chapterSave([

+ 58 - 20
api-v13/app/Http/Controllers/Library/BookController.php

@@ -13,7 +13,7 @@ use App\Models\PaliText;
 use App\Models\Sentence;
 
 use App\Services\ChapterService;
-use App\Services\PaliContentService;
+use App\Services\PaliTextService;
 use App\Services\OpenSearchService;
 
 use App\DTO\Search\HitItemDTO;
@@ -29,9 +29,12 @@ class BookController extends Controller
      *
      * @param  \App\Services\OpenSearchService  $searchService
      */
-    public function __construct(protected OpenSearchService $searchService) {}
+    public function __construct(
+        protected OpenSearchService $searchService,
+        protected PaliTextService $paliTextService
+    ) {}
 
-    public function show($id)
+    public function show(string $id)
     {
         $bookRaw = $this->loadBook($id);
 
@@ -64,31 +67,56 @@ class BookController extends Controller
     }
 
 
+    private function fetchCommentary(int $book, int $paraStart, int $paraEnd, string $channelId)
+    {
+        $notes = Sentence::where('book_id', $book)
+            ->whereBetween('paragraph', [$paraStart, $paraEnd])
+            ->where('channel_uid', $channelId)
+            ->select(['uid', 'book_id', 'paragraph', 'word_start', 'word_end'])->get()->toArray();
+        Log::debug('fetchCommentary', ['data' => $notes]);
+        return $notes;
+    }
+
+    private function injectNoteMarkers(string $html, array $notesMap): string
+    {
+        if (empty($notesMap)) return $html;
+
+        return preg_replace_callback(
+            '/(<div class=\'sentence\' data-sid=\'([^\']+)\')/',
+            function ($matches) use ($notesMap) {
+                $sid = $matches[2];
+                if (!isset($notesMap[$sid])) return $matches[0];
+                $uid = $notesMap[$sid];
+                return $matches[1] . " data-note-id='{$uid}'";
+            },
+            $html
+        );
+    }
     public function read(Request $request, string $id)
     {
-        $start = microtime(true);
-        /*
-        $lap = function (string $label) use ($start): void {
-            $elapsed = round((microtime(true) - $start) * 1000, 2);
-            Log::debug("[book.read] {$label}", ['elapsed_ms' => $elapsed]);
-        };
-*/
-        //$lap('start');
 
         $channelId = $request->input('channel');
         $openSearchId = "tipitaka_chapter_{$id}_{$channelId}";
 
         $chapter = HitItemDTO::fromArray($this->searchService->get($openSearchId))->toArray();
-        //$lap('searchService->get + HitItemDTO');
 
         [$bookId, $paraId] = explode('-', $id);
+        if ($request->has('comm')) {
+            $currChapter = $this->paliTextService->getCurrChapter($bookId, $paraId);
+            $commentaries = $this->fetchCommentary($bookId, $paraId, $paraId + $currChapter->chapter_len - 1, $request->input('comm'));
+            // sid 格式:{book_id}-{paragraph}-{word_start}-{word_end}
+            $notesMap = collect($commentaries)->keyBy(function ($note) {
+                return "{$note['book_id']}-{$note['paragraph']}-{$note['word_start']}-{$note['word_end']}";
+            })->map(fn($note) => $note['uid'])->toArray();
+            Log::debug('note map', ['data' => $notesMap]);
+        }
+
 
         $chapterService = app(ChapterService::class);
 
         $book = [];
 
         $book['toc'] = $this->getBookToc((int)$bookId, (int)$paraId, $channelId, 2, 7);
-        //$lap('getBookToc');
 
         $book['categories'] = $chapter['category'];
         $book['title']      = $chapter['title'];
@@ -96,23 +124,33 @@ class BookController extends Controller
         $book['tags']       = [];
 
         $book['pagination'] = $this->pagination((int)$bookId, (int)$paraId, $channelId);
-        //$lap('pagination');
 
-        $book['content'] = $chapter['display'];
-        Log::debug($chapter['display']);
 
-        $channels = $chapterService->publicChannels((int)$bookId, (int)$paraId);
-        //$lap('publicChannels');
+        if (isset($notesMap)) {
+            $book['content'] = $this->injectNoteMarkers($chapter['display'], $notesMap);
+        } else {
+            $book['content'] = $chapter['display'];
+        }
+        Log::debug($book['content']);
+
+        $allChannels = $chapterService->publicChannels((int)$bookId, (int)$paraId);
+        $commentaryChannels = array_filter($allChannels, function ($channel) {
+            return $channel['type'] === 'commentary';
+        });
+        $channels = array_filter($allChannels, function ($channel) {
+            return $channel['type'] !== 'commentary';
+        });
 
         $editor_link = config('mint.server.dashboard_base_path')
             . "/workspace/tipitaka/chapter/{$id}?channel={$channelId}";
 
-        $view = view('library.book.read', compact('book', 'channels', 'editor_link'));
-        //$lap('view compiled — total');
+        $view = view('library.book.read', compact('book', 'channels', 'editor_link', 'commentaryChannels'));
 
         return $view;
     }
 
+
+
     private function loadBook(string $id)
     {
         $book = ProgressChapter::with('channel.owner')->find($id);

+ 1 - 1
api-v13/app/Services/ChapterService.php

@@ -255,7 +255,7 @@ class ChapterService
         return $result;
     }
 
-    public function publicChannels(int $book, int $para)
+    public function publicChannels(int $book, int $para, ?string $type = null)
     {
         $channelIds = ProgressChapter::with('channel')
             ->where('book', $book)

+ 3 - 3
api-v13/app/Services/OpenSearchService.php

@@ -199,7 +199,7 @@ class OpenSearchService
                             'method'    => [
                                 'name'       => 'hnsw',
                                 'space_type' => 'cosinesimil',
-                                'engine'     => 'nmslib',
+                                'engine'     => 'faiss',
                             ],
                         ],
                         'suggest' => [
@@ -235,7 +235,7 @@ class OpenSearchService
                             'method'    => [
                                 'name'       => 'hnsw',
                                 'space_type' => 'cosinesimil',
-                                'engine'     => 'nmslib',
+                                'engine'     => 'faiss',
                             ],
                         ],
                     ],
@@ -286,7 +286,7 @@ class OpenSearchService
                             'method'    => [
                                 'name'       => 'hnsw',
                                 'space_type' => 'cosinesimil',
-                                'engine'     => 'nmslib',
+                                'engine'     => 'faiss',
                             ],
                         ],
                         'suggest' => [

+ 4 - 0
api-v13/app/Tools/Markdown.php

@@ -31,6 +31,10 @@ class Markdown
      */
     public static function replaceSinglePWithSpan(string $html): string
     {
+        // 先过滤掉只含空白/全角空格的 <p>
+        $html = preg_replace('/<p\b[^>]*>[\s ]*<\/p>/u', '', $html);
+        $html = trim($html);
+
         preg_match_all('/<p\b[^>]*>.*?<\/p>/is', $html, $matches);
 
         if (count($matches[0]) === 1) {

+ 62 - 4
api-v13/resources/css/modules/_reader-content.css

@@ -15,6 +15,12 @@ article.reader-body {
     counter-reset: sidenote-counter;
 }
 
+article.reader-body div.sentence {
+    display: inline;
+}
+article.reader-body div.translation .para-block {
+    margin-bottom: 0.75em;
+}
 /* ── 段落 ── */
 article.reader-body p {
     margin-bottom: 1.25rem;
@@ -99,7 +105,7 @@ article.reader-body label.sidenote-number {
     article.reader-body .marginnote {
         float: right;
         clear: right;
-        margin-right: -35%; /* 收窄,适配 reader 右侧无大空白的布局 */
+        margin-right: -35%;
         width: 28%;
         margin-top: 0.3rem;
         margin-bottom: 0;
@@ -161,10 +167,11 @@ article.reader-body .sidenote::before {
 }
 
 article.reader-body .sidenote-number::after {
-    content: counter(sidenote-counter);
+    content: '[' counter(sidenote-counter) ']';
     font-size: 0.75rem;
     top: -0.5rem;
     left: 0.1rem;
+    color: var(--tblr-primary);
 }
 
 article.reader-body .sidenote::before {
@@ -172,9 +179,60 @@ article.reader-body .sidenote::before {
     font-size: 0.75rem;
     top: -0.5rem;
 }
+/* ══════════════════════════════════════════
+   三、Commentary 注释(点击展开,始终在句子下方)
+   DOM 结构(由 JS 注入):
+     div.sentence
+     input.commentary-toggle#commentary-N
+     div.commentary-note[data-uuid]
+   ══════════════════════════════════════════ */
+
+/* checkbox 隐藏 */
+article.reader-body input.commentary-toggle {
+    display: none;
+}
+
+/* icon label:行内,插在句子文字末尾 */
+article.reader-body label.commentary-icon {
+    display: inline;
+    cursor: pointer;
+    margin-left: 4px;
+    color: var(--tblr-primary);
+    vertical-align: middle;
+    font-size: 0.85rem;
+    user-select: none;
+    transition: opacity 0.15s;
+}
+
+article.reader-body label.commentary-icon:hover {
+    opacity: 0.7;
+}
+
+/* 注释块默认隐藏 */
+article.reader-body .commentary-note {
+    display: none;
+}
+
+/* 展开:checkbox checked → 相邻的 .commentary-note 显示 */
+article.reader-body .commentary-toggle:checked + .commentary-note {
+    display: block;
+    clear: both;
+    width: 95%;
+    margin: 0.5rem 2.5% 0.75rem;
+    padding: 8px 12px;
+    border-left: 3px solid var(--tblr-primary);
+    border-radius: 0 6px 6px 0;
+    background-color: #f5deb330;
+    font-size: 0.875rem;
+    line-height: 1.6;
+}
+
+.dark-mode article.reader-body .commentary-toggle:checked + .commentary-note {
+    background-color: rgba(139, 109, 56, 0.15);
+}
 
 /* ══════════════════════════════════════════
-   三、正文内 origin(原文显示)
+   、正文内 origin(原文显示)
    ══════════════════════════════════════════ */
 
 article.reader-body .origin {
@@ -186,7 +244,7 @@ article.reader-body .origin {
 }
 
 /* ══════════════════════════════════════════
-   、Epigraph(题词)
+   、Epigraph(题词)
    ══════════════════════════════════════════ */
 
 article.reader-body div.epigraph {

+ 1 - 0
api-v13/resources/js/app.js

@@ -1,3 +1,4 @@
+// api-v13/resources/js/app.js
 import './search-suggest';
 import { initNavbar } from './modules/navbar';
 

+ 82 - 0
api-v13/resources/js/modules/_reader.js

@@ -0,0 +1,82 @@
+// resources/js/modules/_reader.js
+
+export function initReader() {
+    injectCommentaryMarkers();
+}
+
+function injectCommentaryMarkers() {
+    let counter = 0;
+
+    document
+        .querySelectorAll('div.sentence[data-note-id]')
+        .forEach((sentenceEl) => {
+            const uid = sentenceEl.dataset.noteId;
+            counter++;
+            const id = `commentary-${counter}`;
+
+            // checkbox:控制展开,紧跟在 sentence 后
+            const checkbox = document.createElement('input');
+            checkbox.type = 'checkbox';
+            checkbox.className = 'commentary-toggle';
+            checkbox.id = id;
+
+            // label(icon):行内,插在句子文字末尾
+            const label = document.createElement('label');
+            label.htmlFor = id;
+            label.className = 'commentary-icon';
+            label.innerHTML = '<i class="ti ti-message-circle"></i>';
+
+            // 注释块:紧跟在 checkbox 后(CSS 相邻选择器依赖此顺序)
+            const note = document.createElement('div');
+            note.className = 'commentary-note';
+            note.dataset.uuid = uid;
+            note.dataset.loaded = 'false';
+
+            // label 插入句子内文字末尾(行内不打断文字流)
+            const innerSpan = sentenceEl.querySelector(':scope > span');
+            (innerSpan ?? sentenceEl).appendChild(label);
+
+            // sentence 后:先插 note,再插 checkbox(after 逆序)
+            // 最终顺序:div.sentence → input.commentary-toggle → div.commentary-note
+            sentenceEl.after(note);
+            sentenceEl.after(checkbox);
+
+            // 点击时懒加载
+            checkbox.addEventListener('change', async () => {
+                if (!checkbox.checked) {
+                    return;
+                }
+
+                if (note.dataset.loaded === 'true') {
+                    return;
+                }
+
+                note.innerHTML =
+                    '<span class="text-muted small">加载中…</span>';
+                await fetchCommentary(note);
+            });
+        });
+}
+
+async function fetchCommentary(noteEl) {
+    const uuid = noteEl.dataset.uuid;
+
+    try {
+        const res = await fetch(`/api/v2/sentence/${uuid}?format=html`);
+
+        if (!res.ok) {
+            throw new Error(res.status);
+        }
+
+        const json = await res.json();
+
+        if (!json.ok) {
+            throw new Error('api error');
+        }
+
+        noteEl.innerHTML = json.data.html;
+        noteEl.dataset.loaded = 'true';
+    } catch {
+        noteEl.innerHTML = '<span class="text-muted small">加载失败</span>';
+    }
+}

+ 5 - 0
api-v13/resources/js/reader.js

@@ -0,0 +1,5 @@
+import { initReader } from './modules/_reader.js';
+
+document.addEventListener('DOMContentLoaded', () => {
+    initReader();
+});

+ 22 - 3
api-v13/resources/views/library/book/read.blade.php

@@ -10,7 +10,7 @@
 
 {{-- 术语抽屉(所有阅读页统一使用 wiki.term-drawer) --}}
 @push('scripts')
-@vite(['resources/js/modules/term-tooltip.js'])
+@vite(['resources/js/modules/term-tooltip.js','resources/js/reader.js'])
 @endpush
 
 @section('reader-content')
@@ -287,7 +287,7 @@
                     </select>
                 </div>
                 <div class="mb-4">
-                    <label class="form-label">巴利脚本</label>
+                    <label class="form-label">巴利脚本</label>
                     <select class="form-select" id="paliScript">
                         <option value="auto">自动</option>
                         <option value="roman">罗马</option>
@@ -295,6 +295,15 @@
                         <option value="thai">泰文</option>
                     </select>
                 </div>
+                <div class="mb-4">
+                    <label class="form-label">注疏版本</label>
+                    <select class="form-select" id="commentary">
+                        <option value="none">不显示</option>
+                        @foreach($commentaryChannels as $channel)
+                        <option value="{{ $channel['id'] }}">{{ $channel['name'] }}</option>
+                        @endforeach
+                    </select>
+                </div>
             </div>
             <div class="modal-footer">
                 <button type="button" class="btn btn-link" data-bs-dismiss="modal">取消</button>
@@ -354,6 +363,7 @@
         document.getElementById('showOrigin').checked = showOrigin;
         document.getElementById('uiLanguage').value = getCookie('ui_language') || 'auto';
         document.getElementById('paliScript').value = getCookie('pali_script') || 'auto';
+        document.getElementById('commentary').value = getCookie('commentary') || 'none';
         toggleOriginDisplay(showOrigin);
     });
 
@@ -362,7 +372,16 @@
         setCookie('show_origin', document.getElementById('showOrigin').checked);
         setCookie('ui_language', document.getElementById('uiLanguage').value);
         setCookie('pali_script', document.getElementById('paliScript').value);
-        location.reload();
+        setCookie('commentary', document.getElementById('commentary').value);
+        // 修改 URL 的 comm 参数
+        const commValue = document.getElementById('commentary').value;
+        const url = new URL(window.location.href);
+        if (commValue === 'none') {
+            url.searchParams.delete('comm');
+        } else {
+            url.searchParams.set('comm', commValue);
+        }
+        window.location.href = url.toString();
     });
 </script>
 @endpush