visuddhinanda пре 1 месец
родитељ
комит
bd846aa86e

+ 4 - 0
api-v12/app/Console/Commands/UpgradeSystemCommentary.php

@@ -297,6 +297,10 @@ md;
     {
         $output  = [];
         foreach ($input as $key => $value) {
+            if (!isset($original[$key])) {
+                Log::warning('no id');
+                continue;
+            }
             $value['id'] = $original[$key]['id'];
             if (isset($value['commentary'])) {
                 $newCommentary = array_map(function ($n) use ($commentary) {

+ 300 - 0
api-v12/app/Http/Controllers/ChapterContentController.php

@@ -0,0 +1,300 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
+
+use App\Models\Sentence;
+use App\Models\Channel;
+use App\Models\PaliText;
+
+use Illuminate\Support\Str;
+use App\Http\Api\MdRender;
+use App\Http\Api\ChannelApi;
+use App\Http\Api\StudioApi;
+
+
+use App\Http\Api\AuthApi;
+use App\Http\Resources\TocResource;
+use App\Services\PaliContentService;
+
+class ChapterContentController extends Controller
+{
+    protected $result = [
+        "uid" => '',
+        "title" => '',
+        "path" => [],
+        "sub_title" => '',
+        "summary" => '',
+        "content" => '',
+        "content_type" => "html",
+        "toc" => [],
+        "status" => 30,
+        "lang" => "",
+        "created_at" => "",
+        "updated_at" => "",
+    ];
+
+    protected $selectCol = [
+        'uid',
+        'book_id',
+        'paragraph',
+        'word_start',
+        "word_end",
+        'channel_uid',
+        'content',
+        'content_type',
+        'editor_uid',
+        'acceptor_uid',
+        'pr_edit_at',
+        'fork_at',
+        'create_time',
+        'modify_time',
+        'created_at',
+        'updated_at',
+    ];
+    protected $wbwChannels = [];
+    protected $debug = [];
+    protected $userUuid = null;
+
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+
+     * @param  \Illuminate\Http\Request  $request
+     * @param string $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $request, string $id)
+    {
+        $paliService = app(PaliContentService::class);
+        if ($request->has('debug')) {
+            $this->debug = explode(',', $request->get('debug'));
+        }
+        $user = AuthApi::current($request);
+        if ($user) {
+            $this->userUuid = $user['user_uid'];
+        }
+        //
+        $sentId = \explode('-', $id);
+        $channels = [];
+        if ($request->has('channels')) {
+            if (strpos($request->get('channels'), ',') === FALSE) {
+                $_channels = explode('_', $request->get('channels'));
+            } else {
+                $_channels = explode(',', $request->get('channels'));
+            }
+            foreach ($_channels as $key => $channel) {
+                if (Str::isUuid($channel)) {
+                    $channels[] = $channel;
+                }
+            }
+        }
+
+        $mode = $request->get('mode', 'read');
+        if ($mode === 'read') {
+            //阅读模式加载html格式原文
+            $channelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
+        } else {
+            //翻译模式加载json格式原文
+            $channelId = ChannelApi::getSysChannel('_System_Wbw_VRI_');
+        }
+
+        if ($channelId !== false) {
+            $channels[] = $channelId;
+        }
+
+        $chapter = PaliText::where('book', $sentId[0])->where('paragraph', $sentId[1])->first();
+        if (!$chapter) {
+            return $this->error("no data");
+        }
+        $paraFrom = $sentId[1];
+        $paraTo = $sentId[1] + $chapter->chapter_len - 1;
+
+        if (empty($chapter->toc)) {
+            $this->result['title'] = "unknown";
+        } else {
+            $this->result['title'] = $chapter->toc;
+            $this->result['sub_title'] = $chapter->toc;
+            $this->result['path'] = json_decode($chapter->path);
+        }
+
+        //获取标题
+        $heading = PaliText::select(["book", "paragraph", "level"])
+            ->where('book', $sentId[0])
+            ->whereBetween('paragraph', [$paraFrom, $paraTo])
+            ->where('level', '<', 8)
+            ->get();
+        //将标题段落转成索引数组 以便输出标题层级
+        $indexedHeading = [];
+        foreach ($heading as $key => $value) {
+            # code...
+            $indexedHeading["{$value->book}-{$value->paragraph}"] = $value->level;
+        }
+        #获取channel索引表
+        $tranChannels = [];
+        $channelInfo = Channel::whereIn("uid", $channels)
+            ->select(['uid', 'type', 'lang', 'name'])->get();
+        foreach ($channelInfo as $key => $value) {
+            # code...
+            if ($value->type === "translation") {
+                $tranChannels[] = $value->uid;
+            }
+        }
+        $indexChannel = [];
+        $indexChannel = $paliService->getChannelIndex($channels);
+        //获取wbw channel
+        //目前默认的 wbw channel 是第一个translation channel
+        //TODO 处理不存在的channel id
+        foreach ($channels as $key => $value) {
+            # code...
+            if (
+                isset($indexChannel[$value]) &&
+                $indexChannel[$value]->type === 'translation'
+            ) {
+                $this->wbwChannels[] = $value;
+                break;
+            }
+        }
+        $title = Sentence::select($this->selectCol)
+            ->where('book_id', $sentId[0])
+            ->where('paragraph', $sentId[1])
+            ->whereIn('channel_uid', $tranChannels)
+            ->first();
+        if ($title) {
+            $this->result['title'] = MdRender::render($title->content, [$title->channel_uid]);
+            $mdRender = new MdRender(['format' => 'simple']);
+            $this->result['title_text'] = $mdRender->convert($title->content, [$title->channel_uid]);
+        }
+
+        /**
+         * 获取句子数据
+         * 算法:
+         * 1. 如果标题和下一级第一个标题之间有段落。只输出这些段落和子目录
+         * 2. 如果标题和下一级第一个标题之间没有间隔 且 chapter 长度大于10000个字符 且有子目录,只输出子目录
+         * 3. 如果二者都不是,lazy load
+         */
+        //1. 计算 标题和下一级第一个标题之间 是否有间隔
+        $nextChapter =  PaliText::where('book', $sentId[0])
+            ->where('paragraph', ">", $sentId[1])
+            ->where('level', '<', 8)
+            ->orderBy('paragraph')
+            ->value('paragraph');
+        $between = $nextChapter - $sentId[1];
+        //查找子目录
+        $chapterLen = $chapter->chapter_len;
+        $toc = PaliText::where('book', $sentId[0])
+            ->whereBetween('paragraph', [$paraFrom + 1, $paraFrom + $chapterLen - 1])
+            ->where('level', '<', 8)
+            ->orderBy('paragraph')
+            ->select(['book', 'paragraph', 'level', 'toc'])
+            ->get();
+        $maxLen = 3000;
+        if ($between > 1) {
+            //有间隔
+            $paraTo = $nextChapter - 1;
+        } else {
+            if ($chapter->chapter_strlen > $maxLen) {
+                if (count($toc) > 0) {
+                    //有子目录只输出标题和目录
+                    $paraTo = $paraFrom;
+                } else {
+                    //没有子目录 全部输出
+                }
+            } else {
+                //章节小。全部输出 不输出子目录
+                $toc = [];
+            }
+        }
+
+        $pFrom = $request->get('from', $paraFrom);
+        $pTo = $request->get('to', $paraTo);
+        //根据句子的长度找到这次应该加载的段落
+
+        $paliText = PaliText::select(['paragraph', 'lenght'])
+            ->where('book', $sentId[0])
+            ->whereBetween('paragraph', [$pFrom, $pTo])
+            ->orderBy('paragraph')
+            ->get();
+        $sumLen = 0;
+        $currTo = $pTo;
+        foreach ($paliText as $para) {
+            $sumLen += $para->lenght;
+            if ($sumLen > $maxLen) {
+                $currTo = $para->paragraph;
+                break;
+            }
+        }
+        $record = Sentence::select($this->selectCol)
+            ->where('book_id', $sentId[0])
+            ->whereBetween('paragraph', [$pFrom, $currTo])
+            ->whereIn('channel_uid', $channels)
+            ->orderBy('paragraph')
+            ->orderBy('word_start')
+            ->get();
+        if (count($record) === 0) {
+            return $this->error("no data");
+        }
+        $this->result['content'] = json_encode($paliService->makeContentObj($record, $mode, $indexChannel), JSON_UNESCAPED_UNICODE);
+        $this->result['content_type'] = 'json';
+        if (!$request->has('from')) {
+            //第一次才显示toc
+            $this->result['toc'] = TocResource::collection($toc);
+        }
+        if ($currTo < $pTo) {
+            $this->result['from'] = $currTo + 1;
+            $this->result['to'] = $pTo;
+            $this->result['paraId'] = $id;
+            $this->result['channels'] = $request->get('channels');
+            $this->result['mode'] = $request->get('mode');
+        }
+
+        return $this->ok($this->result);
+    }
+
+
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 10 - 3
api-v12/app/Http/Controllers/ChapterController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use App\Models\PaliText;
 use Illuminate\Http\Request;
 use App\Http\Resources\ChapterResource;
+use App\Services\PaliTextService;
 
 class ChapterController extends Controller
 {
@@ -60,12 +61,18 @@ class ChapterController extends Controller
     /**
      * Display the specified resource.
      *
-     * @param  \App\Models\PaliText  $paliText
+     * @param  string  $id
      * @return \Illuminate\Http\Response
      */
-    public function show(PaliText $paliText)
+    public function show(string $id)
     {
-        //
+        $para = explode('-', $id);
+        if (count($para) < 2) {
+            return $this->error('参数错误', 400, 400);
+        }
+        $paliTextService = app(PaliTextService::class);
+        $paragraph = $paliTextService->getCurrChapter($para[0], $para[1]);
+        return $this->ok(new ChapterResource($paragraph));
     }
 
     /**

+ 1 - 3
api-v12/app/Http/Controllers/CorpusController.php

@@ -7,13 +7,11 @@ use Carbon\Carbon;
 use App\Models\Sentence;
 use App\Models\Channel;
 use App\Models\PaliText;
-use App\Models\WbwTemplate;
 use App\Models\WbwBlock;
 use App\Models\Wbw;
 use App\Models\Discussion;
 use App\Models\PaliSentence;
 use App\Models\SentSimIndex;
-use App\Models\CustomBookSentence;
 use App\Models\CustomBook;
 
 use Illuminate\Support\Str;
@@ -532,7 +530,7 @@ class CorpusController extends Controller
         if (count($record) === 0) {
             return $this->error("no data");
         }
-        $this->result['content'] = json_encode($this->makeContentObj($record, $mode, $indexChannel), JSON_UNESCAPED_UNICODE);
+        $this->result['content'] = json_encode($this->makeContent($record, $mode, $indexChannel), JSON_UNESCAPED_UNICODE);
         $this->result['content_type'] = 'json';
         if (!$request->has('from')) {
             //第一次才显示toc

+ 115 - 0
api-v12/app/Http/Controllers/ParagraphContentController.php

@@ -0,0 +1,115 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+use App\Models\Sentence;
+use App\Models\PaliText;
+use Illuminate\Support\Str;
+use App\Http\Api\ChannelApi;
+
+
+
+
+use App\Services\PaliContentService;
+
+class ParagraphContentController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *      * @param  \Illuminate\Http\Request  $request
+     * @param string $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $request, string $id)
+    {
+        $paliService = app(PaliContentService::class);
+
+        //
+        $sentId = \explode('-', $id);
+        $channels = [];
+        if ($request->has('channels')) {
+            if (strpos($request->get('channels'), ',') === FALSE) {
+                $_channels = explode('_', $request->get('channels'));
+            } else {
+                $_channels = explode(',', $request->get('channels'));
+            }
+            foreach ($_channels as $key => $channel) {
+                if (Str::isUuid($channel)) {
+                    $channels[] = $channel;
+                }
+            }
+        }
+
+        $mode = $request->get('mode', 'read');
+        if ($mode === 'read') {
+            //阅读模式加载html格式原文
+            $channelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
+        } else {
+            //翻译模式加载json格式原文
+            $channelId = ChannelApi::getSysChannel('_System_Wbw_VRI_');
+        }
+
+        if ($channelId !== false) {
+            $channels[] = $channelId;
+        }
+
+        $chapter = PaliText::where('book', $sentId[0])->where('paragraph', $sentId[1])->first();
+        if (!$chapter) {
+            return $this->error("no data");
+        }
+
+        #获取channel索引表
+
+        $indexChannel = [];
+        $indexChannel = $paliService->getChannelIndex($channels);
+        $from = $sentId[1];
+        $to =  $sentId[2] ?? $sentId[1];
+        $record = Sentence::where('book_id', $sentId[0])
+            ->whereBetween('paragraph', [$from, $to])
+            ->whereIn('channel_uid', $channels)
+            ->orderBy('paragraph')
+            ->orderBy('word_start')
+            ->get();
+        if (count($record) === 0) {
+            return $this->error("no data");
+        }
+        $result = [];
+        $result['content'] = json_encode($paliService->makeContentObj($record, $mode, $indexChannel), JSON_UNESCAPED_UNICODE);
+        $result['content_type'] = 'json';
+        return $this->ok($result);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     */
+    public function update(Request $request, string $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     */
+    public function destroy(string $id)
+    {
+        //
+    }
+}

+ 567 - 0
api-v12/app/Services/PaliContentService.php

@@ -0,0 +1,567 @@
+<?php
+// api-v8/app/Services/OpenSearchService.php
+namespace App\Services;
+
+use App\Models\Sentence;
+use App\Models\Channel;
+use App\Models\PaliText;
+use App\Models\WbwBlock;
+use App\Models\Wbw;
+use App\Models\Discussion;
+use App\Models\PaliSentence;
+use App\Models\SentSimIndex;
+
+
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Cache;
+use App\Http\Api\MdRender;
+use App\Http\Api\SuggestionApi;
+use App\Http\Api\ChannelApi;
+use App\Http\Api\UserApi;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Arr;
+use App\Http\Api\StudioApi;
+
+class PaliContentService
+{
+    protected $result = [
+        "uid" => '',
+        "title" => '',
+        "path" => [],
+        "sub_title" => '',
+        "summary" => '',
+        "content" => '',
+        "content_type" => "html",
+        "toc" => [],
+        "status" => 30,
+        "lang" => "",
+        "created_at" => "",
+        "updated_at" => "",
+    ];
+    protected $wbwChannels = [];
+    //句子需要查询的列
+    protected $selectCol = [
+        'uid',
+        'book_id',
+        'paragraph',
+        'word_start',
+        "word_end",
+        'channel_uid',
+        'content',
+        'content_type',
+        'editor_uid',
+        'acceptor_uid',
+        'pr_edit_at',
+        'fork_at',
+        'create_time',
+        'modify_time',
+        'created_at',
+        'updated_at',
+    ];
+
+    protected $userUuid = null;
+
+    protected $debug = [];
+
+    public static function _sentCanReadCount($book, $para, $start, $end, $userUuid = null)
+    {
+        $keyCanRead = "/channel/can-read/";
+        if ($userUuid) {
+            $keyCanRead .= $userUuid;
+        } else {
+            $keyCanRead .= 'guest';
+        }
+        $channelCanRead = Cache::remember(
+            $keyCanRead,
+            config('mint.cache.expire'),
+            function () use ($userUuid) {
+                return ChannelApi::getCanReadByUser($userUuid);
+            }
+        );
+        $channels =  Sentence::where('book_id', $book)
+            ->where('paragraph', $para)
+            ->where('word_start', $start)
+            ->where('word_end', $end)
+            ->where('strlen', '<>', 0)
+            ->whereIn('channel_uid', $channelCanRead)
+            ->select('channel_uid')
+            ->groupBy('channel_uid')
+            ->get();
+        $channelList = [];
+        foreach ($channels as $key => $value) {
+            # code...
+            if (Str::isUuid($value->channel_uid)) {
+                $channelList[] = $value->channel_uid;
+            }
+        }
+        $simId = PaliSentence::where('book', $book)
+            ->where('paragraph', $para)
+            ->where('word_begin', $start)
+            ->where('word_end', $end)
+            ->value('id');
+        if ($simId) {
+            $output["simNum"] = SentSimIndex::where('sent_id', $simId)->value('count');
+        } else {
+            $output["simNum"] = 0;
+        }
+        $channelInfo = Channel::whereIn("uid", $channelList)->select('type')->get();
+        $output["tranNum"] = 0;
+        $output["nissayaNum"] = 0;
+        $output["commNum"] = 0;
+        $output["originNum"] = 0;
+
+        foreach ($channelInfo as $key => $value) {
+            # code...
+            switch ($value->type) {
+                case "translation":
+                    $output["tranNum"]++;
+                    break;
+                case "nissaya":
+                    $output["nissayaNum"]++;
+                    break;
+                case "commentary":
+                    $output["commNum"]++;
+                    break;
+                case "original":
+                    $output["originNum"]++;
+                    break;
+            }
+        }
+        return $output;
+    }
+    private function newSent($book, $para, $word_start, $word_end)
+    {
+        $sent = [
+            "id" => "{$book}-{$para}-{$word_start}-{$word_end}",
+            "book" => $book,
+            "para" => $para,
+            "wordStart" => $word_start,
+            "wordEnd" => $word_end,
+            "origin" => [],
+            "translation" => [],
+            "commentaries" => [],
+        ];
+
+        if ($book < 1000) {
+            #生成channel 数量列表
+            $sentId = "{$book}-{$para}-{$word_start}-{$word_end}";
+            $channelCount = self::_sentCanReadCount($book, $para, $word_start, $word_end, $this->userUuid);
+            $path = json_decode(PaliText::where('book', $book)->where('paragraph', $para)->value("path"), true);
+            $sent["path"] = [];
+            foreach ($path as $key => $value) {
+                # code...
+                $value['paliTitle'] = $value['title'];
+                $sent["path"][] = $value;
+            }
+            $sent["tranNum"] = $channelCount['tranNum'];
+            $sent["nissayaNum"] = $channelCount['nissayaNum'];
+            $sent["commNum"] = $channelCount['commNum'];
+            $sent["originNum"] = $channelCount['originNum'];
+            $sent["simNum"] = $channelCount['simNum'];
+        }
+
+        return $sent;
+    }
+    /**
+     * 根据句子库数据生成以段落为单位的文章内容
+     * $record 句子数据
+     * $mode read | edit | wbw
+     * $indexChannel channel索引
+     * $indexedHeading 标题索引 用于给段落加标题标签 <h1> ect.
+     */
+    public function makeContentObj($record, $mode, $indexChannel, $format = 'react')
+    {
+        $content = [];
+
+
+        //获取句子编号列表
+        $paraIndex = [];
+        foreach ($record as  $value) {
+            $currSentId = "{$value->book_id}-{$value->paragraph}-{$value->word_start}-{$value->word_end}";
+            $value->sid = "{$currSentId}_{$value->channel_uid}";
+
+            $currParaId = "{$value->book_id}-{$value->paragraph}";
+            if (!isset($paraIndex[$currParaId])) {
+                $paraIndex[$currParaId] = [];
+            }
+            $paraIndex[$currParaId][] = $value;
+        }
+        $channelsId = array();
+        foreach ($indexChannel as $channelId => $info) {
+            $channelsId[] = $channelId;
+        }
+        array_pop($channelsId);
+        //遍历列表查找每个句子的所有channel的数据,并填充
+        $paragraphs = [];
+        foreach ($paraIndex as $currParaId => $sentData) {
+            $arrParaId = explode('-', $currParaId);
+            $sentIndex = [];
+            foreach ($sentData as  $sent) {
+                $currSentId = "{$sent->book_id}-{$sent->paragraph}-{$sent->word_start}-{$sent->word_end}";
+                $sentIndex[$currSentId] = [$sent->book_id, $sent->paragraph, $sent->word_start, $sent->word_end];
+            }
+            $sentInPara = array_values($sentIndex);
+            $paraProps = [
+                'book' => $arrParaId[0],
+                'para' => $arrParaId[1],
+                'channels' => $channelsId,
+                'sentences' => $sentInPara,
+                'mode' => $mode,
+                'children' => [],
+            ];
+            //建立段落里面的句子列表
+            foreach ($sentIndex as $ids => $arrSentId) {
+                $sentNode = $this->newSent($arrSentId[0], $arrSentId[1], $arrSentId[2], $arrSentId[3]);
+                foreach ($indexChannel as $channelId => $info) {
+                    # code...
+                    $sid = "{$ids}_{$channelId}";
+                    if (isset($info->studio)) {
+                        $studioInfo = $info->studio;
+                    } else {
+                        $studioInfo = null;
+                    }
+                    $newSent = [
+                        "content" => "",
+                        "html" => "",
+                        "book" => $arrSentId[0],
+                        "para" => $arrSentId[1],
+                        "wordStart" => $arrSentId[2],
+                        "wordEnd" => $arrSentId[3],
+                        "channel" => [
+                            "name" => $info->name,
+                            "type" => $info->type,
+                            "id" => $info->uid,
+                            'lang' => $info->lang,
+                        ],
+                        "studio" => $studioInfo,
+                        "updateAt" => "",
+                        "suggestionCount" => SuggestionApi::getCountBySent($arrSentId[0], $arrSentId[1], $arrSentId[2], $arrSentId[3], $channelId),
+                    ];
+
+                    $row = Arr::first($sentData, function ($value, $key) use ($sid) {
+                        return $value->sid === $sid;
+                    });
+                    if ($row) {
+                        $newSent['id'] = $row->uid;
+                        $newSent['content'] = $row->content;
+                        $newSent['contentType'] = $row->content_type;
+                        $newSent['html'] = '';
+                        $newSent["editor"] = UserApi::getByUuid($row->editor_uid);
+                        /**
+                         * TODO 刷库改数据
+                         * 旧版api没有更新updated_at所以造成旧版的数据updated_at数据比modify_time 要晚
+                         */
+                        $newSent['forkAt'] =  $row->fork_at; //
+                        $newSent['updateAt'] =  $row->updated_at; //
+                        $newSent['updateAt'] = date("Y-m-d H:i:s.", $row->modify_time / 1000) . ($row->modify_time % 1000) . " UTC";
+
+                        $newSent['createdAt'] = $row->created_at;
+                        if ($mode !== "read") {
+                            if (isset($row->acceptor_uid) && !empty($row->acceptor_uid)) {
+                                $newSent["acceptor"] = UserApi::getByUuid($row->acceptor_uid);
+                                $newSent["prEditAt"] = $row->pr_edit_at;
+                            }
+                        }
+                        switch ($info->type) {
+                            case 'wbw':
+                            case 'original':
+                                //
+                                // 在编辑模式下。
+                                // 如果是原文,查看是否有逐词解析数据,
+                                // 有的话优先显示。
+                                // 阅读模式直接显示html原文
+                                // 传过来的数据一定有一个原文channel
+                                //
+                                if ($mode === "read") {
+                                    $newSent['content'] = "";
+                                    $newSent['html'] = MdRender::render(
+                                        $row->content,
+                                        [$row->channel_uid],
+                                        null,
+                                        $mode,
+                                        "translation",
+                                        $row->content_type,
+                                        $format
+                                    );
+                                } else {
+                                    if ($row->content_type === 'json') {
+                                        $newSent['channel']['type'] = "wbw";
+                                        if (isset($this->wbwChannels[0])) {
+                                            $newSent['channel']['name'] = $indexChannel[$this->wbwChannels[0]]->name;
+                                            $newSent['channel']['lang'] = $indexChannel[$this->wbwChannels[0]]->lang;
+                                            $newSent['channel']['id'] = $this->wbwChannels[0];
+                                            //存在一个translation channel
+                                            //尝试查找逐词解析数据。找到,替换现有数据
+                                            $wbwData = $this->getWbw(
+                                                $arrSentId[0],
+                                                $arrSentId[1],
+                                                $arrSentId[2],
+                                                $arrSentId[3],
+                                                $this->wbwChannels[0]
+                                            );
+                                            if ($wbwData) {
+                                                $newSent['content'] = $wbwData;
+                                                $newSent['contentType'] = 'json';
+                                                $newSent['html'] = "";
+                                                $newSent['studio'] = $indexChannel[$this->wbwChannels[0]]->studio;
+                                            }
+                                        }
+                                    } else {
+                                        $newSent['content'] = $row->content;
+                                        $newSent['html'] = MdRender::render(
+                                            $row->content,
+                                            [$row->channel_uid],
+                                            null,
+                                            $mode,
+                                            "translation",
+                                            $row->content_type,
+                                            $format
+                                        );
+                                    }
+                                }
+
+                                break;
+                            case 'nissaya':
+                                $newSent['html'] = Cache::remember(
+                                    "/sent/{$channelId}/{$ids}/{$format}",
+                                    config('mint.cache.expire'),
+                                    function () use ($row, $mode, $format) {
+                                        if ($row->content_type === 'markdown') {
+                                            return MdRender::render(
+                                                $row->content,
+                                                [$row->channel_uid],
+                                                null,
+                                                $mode,
+                                                "nissaya",
+                                                $row->content_type,
+                                                $format
+                                            );
+                                        } else {
+                                            return null;
+                                        }
+                                    }
+                                );
+                                break;
+                            case 'commentary':
+                                $options = [
+                                    'debug' => $this->debug,
+                                    'format' => $format,
+                                    'mode' => $mode,
+                                    'channelType' => 'translation',
+                                    'contentType' => $row->content_type,
+                                ];
+                                $mdRender = new MdRender($options);
+                                $newSent['html'] = $mdRender->convert($row->content, $channelsId);
+                                break;
+                            default:
+                                $options = [
+                                    'debug' => $this->debug,
+                                    'format' => $format,
+                                    'mode' => $mode,
+                                    'channelType' => 'translation',
+                                    'contentType' => $row->content_type,
+                                ];
+                                $mdRender = new MdRender($options);
+                                $newSent['html'] = $mdRender->convert($row->content, [$row->channel_uid]);
+                                //Log::debug('md render', ['content' => $row->content, 'options' => $options, 'render' => $newSent['html']]);
+                                break;
+                        }
+                    } else {
+                        Log::warning('no sentence record');
+                    }
+                    switch ($info->type) {
+                        case 'wbw':
+                        case 'original':
+                            array_push($sentNode["origin"], $newSent);
+                            break;
+                        case 'commentary':
+                            array_push($sentNode["commentaries"], $newSent);
+                            break;
+                        default:
+                            array_push($sentNode["translation"], $newSent);
+                            break;
+                    }
+                }
+                $paraProps['children'][] = $sentNode;
+            }
+            $paragraphs[] = $paraProps;
+        }
+        return $paragraphs;
+    }
+
+    public function getWbw($book, $para, $start, $end, $channel)
+    {
+        /**
+         * 非阅读模式下。原文使用逐词解析数据。
+         * 优先加载第一个translation channel 如果没有。加载默认逐词解析。
+         */
+
+        //获取逐词解析数据
+        $wbwBlock = WbwBlock::where('channel_uid', $channel)
+            ->where('book_id', $book)
+            ->where('paragraph', $para)
+            ->select('uid')
+            ->first();
+        if (!$wbwBlock) {
+            return false;
+        }
+        //找到逐词解析数据
+        $wbwData = Wbw::where('block_uid', $wbwBlock->uid)
+            ->whereBetween('wid', [$start, $end])
+            ->select(['book_id', 'paragraph', 'wid', 'data', 'uid', 'editor_id', 'created_at', 'updated_at'])
+            ->orderBy('wid')
+            ->get();
+        $wbwContent = [];
+        foreach ($wbwData as $wbwrow) {
+            $wbw = str_replace("&nbsp;", ' ', $wbwrow->data);
+            $wbw = str_replace("<br>", ' ', $wbw);
+
+            $xmlString = "<root>" . $wbw . "</root>";
+            try {
+                $xmlWord = simplexml_load_string($xmlString);
+            } catch (\Exception $e) {
+                Log::error('corpus', ['error' => $e]);
+                continue;
+            }
+            $wordsList = $xmlWord->xpath('//word');
+            foreach ($wordsList as $word) {
+                $case = \str_replace(['#', '.'], ['$', ''], $word->case->__toString());
+                $case = \str_replace('$$', '$', $case);
+                $case = trim($case);
+                $case = trim($case, "$");
+                $wbwId = explode('-', $word->id->__toString());
+
+                $wbwData = [
+                    'uid' => $wbwrow->uid,
+                    'book' => $wbwrow->book_id,
+                    'para' => $wbwrow->paragraph,
+                    'sn' => array_slice($wbwId, 2),
+                    'word' => ['value' => $word->pali->__toString(), 'status' => 0],
+                    'real' => ['value' => $word->real->__toString(), 'status' => 0],
+                    'meaning' => ['value' => $word->mean->__toString(), 'status' => 0],
+                    'type' => ['value' => $word->type->__toString(), 'status' => 0],
+                    'grammar' => ['value' => $word->gramma->__toString(), 'status' => 0],
+                    'case' => ['value' => $word->case->__toString(), 'status' => 0],
+                    'parent' => ['value' => $word->parent->__toString(), 'status' => 0],
+                    'style' => ['value' => $word->style->__toString(), 'status' => 0],
+                    'factors' => ['value' => $word->org->__toString(), 'status' => 0],
+                    'factorMeaning' => ['value' => $word->om->__toString(), 'status' => 0],
+                    'confidence' => $word->cf->__toString(),
+                    'created_at' => $wbwrow->created_at,
+                    'updated_at' => $wbwrow->updated_at,
+                    'hasComment' => Discussion::where('res_id', $wbwrow->uid)->exists(),
+                ];
+                if (isset($word->parent2)) {
+                    $wbwData['parent2']['value'] = $word->parent2->__toString();
+                    if (isset($word->parent2['status'])) {
+                        $wbwData['parent2']['status'] = (int)$word->parent2['status'];
+                    } else {
+                        $wbwData['parent2']['status'] = 0;
+                    }
+                }
+                if (isset($word->pg)) {
+                    $wbwData['grammar2']['value'] = $word->pg->__toString();
+                    if (isset($word->pg['status'])) {
+                        $wbwData['grammar2']['status'] = (int)$word->pg['status'];
+                    } else {
+                        $wbwData['grammar2']['status'] = 0;
+                    }
+                }
+                if (isset($word->rela)) {
+                    $wbwData['relation']['value'] = $word->rela->__toString();
+                    if (isset($word->rela['status'])) {
+                        $wbwData['relation']['status'] = (int)$word->rela['status'];
+                    } else {
+                        $wbwData['relation']['status'] = 7;
+                    }
+                }
+                if (isset($word->bmt)) {
+                    $wbwData['bookMarkText']['value'] = $word->bmt->__toString();
+                    if (isset($word->bmt['status'])) {
+                        $wbwData['bookMarkText']['status'] = (int)$word->bmt['status'];
+                    } else {
+                        $wbwData['bookMarkText']['status'] = 7;
+                    }
+                }
+                if (isset($word->bmc)) {
+                    $wbwData['bookMarkColor']['value'] = $word->bmc->__toString();
+                    if (isset($word->bmc['status'])) {
+                        $wbwData['bookMarkColor']['status'] = (int)$word->bmc['status'];
+                    } else {
+                        $wbwData['bookMarkColor']['status'] = 7;
+                    }
+                }
+                if (isset($word->note)) {
+                    $wbwData['note']['value'] = $word->note->__toString();
+                    if (isset($word->note['status'])) {
+                        $wbwData['note']['status'] = (int)$word->note['status'];
+                    } else {
+                        $wbwData['note']['status'] = 7;
+                    }
+                }
+                if (isset($word->cf)) {
+                    $wbwData['confidence'] = (float)$word->cf->__toString();
+                }
+                if (isset($word->attachments)) {
+                    $wbwData['attachments'] = json_decode($word->attachments->__toString());
+                }
+                if (isset($word->pali['status'])) {
+                    $wbwData['word']['status'] = (int)$word->pali['status'];
+                }
+                if (isset($word->real['status'])) {
+                    $wbwData['real']['status'] = (int)$word->real['status'];
+                }
+                if (isset($word->mean['status'])) {
+                    $wbwData['meaning']['status'] = (int)$word->mean['status'];
+                }
+                if (isset($word->type['status'])) {
+                    $wbwData['type']['status'] = (int)$word->type['status'];
+                }
+                if (isset($word->gramma['status'])) {
+                    $wbwData['grammar']['status'] = (int)$word->gramma['status'];
+                }
+                if (isset($word->case['status'])) {
+                    $wbwData['case']['status'] = (int)$word->case['status'];
+                }
+                if (isset($word->parent['status'])) {
+                    $wbwData['parent']['status'] = (int)$word->parent['status'];
+                }
+                if (isset($word->org['status'])) {
+                    $wbwData['factors']['status'] = (int)$word->org['status'];
+                }
+                if (isset($word->om['status'])) {
+                    $wbwData['factorMeaning']['status'] = (int)$word->om['status'];
+                }
+
+                $wbwContent[] = $wbwData;
+            }
+        }
+        if (count($wbwContent) === 0) {
+            return false;
+        }
+        return \json_encode($wbwContent, JSON_UNESCAPED_UNICODE);
+    }
+
+    public function getChannelIndex($channels, $type = null)
+    {
+        #获取channel索引表
+        $channelInfo = Channel::whereIn("uid", $channels)
+            ->select(['uid', 'type', 'name', 'lang', 'owner_uid'])
+            ->get();
+        $indexChannel = [];
+        foreach ($channels as $key => $channelId) {
+            $channelInfo = Channel::where("uid", $channelId)
+                ->select(['uid', 'type', 'name', 'lang', 'owner_uid'])->first();
+            if (!$channelInfo) {
+                Log::error('no channel id' . $channelId);
+                continue;
+            }
+            if ($type !== null && $channelInfo->type !== $type) {
+                continue;
+            }
+            $indexChannel[$channelId] = $channelInfo;
+            $indexChannel[$channelId]->studio = StudioApi::getById($channelInfo->owner_uid);
+        }
+        return $indexChannel;
+    }
+}

+ 22 - 0
api-v12/app/Services/PaliTextService.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\PaliText;
+
+class PaliTextService
+{
+    public function getParent(int $book, int $para) {}
+    public function getCurrChapter(int $book, int $para)
+    {
+        $paragraph = PaliText::where('book', $book)
+            ->where('paragraph', '<=', $para)
+            ->where('level', '<', 8)
+            ->orderBy('paragraph', 'desc')->first();
+        if ($paragraph) {
+            return $paragraph;
+        } else {
+            return null;
+        }
+    }
+}

+ 4 - 0
api-v12/routes/api.php

@@ -123,6 +123,8 @@ use App\Http\Controllers\ChatMessageController;
 use App\Http\Controllers\SearchPlusController;
 use App\Http\Controllers\SearchSuggestController;
 use App\Http\Controllers\UpgradeController;
+use App\Http\Controllers\ChapterContentController;
+use App\Http\Controllers\ParagraphContentController;
 
 
 
@@ -303,6 +305,8 @@ Route::group(['prefix' => 'v2'], function () {
     Route::apiResource('system-model', SysModelController::class);
     Route::apiResource('chats', ChatController::class);
     Route::apiResource('chat-messages', ChatMessageController::class);
+    Route::apiResource('chapter-content', ChapterContentController::class);
+    Route::apiResource('paragraph-content', ParagraphContentController::class);
 
     Route::post('mock/openai/chat/completions', [MockOpenAIController::class, 'chatCompletions']);
     Route::post('mock/openai/completions', [MockOpenAIController::class, 'completions']);

+ 2 - 2
dashboard-v6/src/api/pali-text.ts

@@ -137,7 +137,7 @@ export const fetchChapter = (
   mode: "read" | "edit",
   channelId?: string | null
 ): Promise<IArticleResponse> => {
-  let url = `/api/v2/corpus-chapter/${articleId}?mode=${mode}`;
+  let url = `/api/v2/chapter-content/${articleId}?mode=${mode}`;
   if (channelId) url += `&channels=${channelId}`;
   return get<IArticleResponse>(url);
 };
@@ -160,7 +160,7 @@ export const fetchNextParaChunk = (
   to: number,
   channelId?: string | null
 ): Promise<IArticleResponse> => {
-  let url = `/api/v2/corpus-chapter/${paraId}?mode=${mode}&from=${from}&to=${to}`;
+  let url = `/api/v2/chapter-content/${paraId}?mode=${mode}&from=${from}&to=${to}`;
   if (channelId) url += `&channels=${channelId}`;
   return get<IArticleResponse>(url);
 };

+ 13 - 1
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -5,6 +5,7 @@ import {
   FieldTimeOutlined,
   FolderOutlined,
   FileOutlined,
+  SettingOutlined,
 } from "@ant-design/icons";
 import { useNavigate, useMatches, type UIMatch } from "react-router";
 import {
@@ -21,6 +22,7 @@ import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
 import { useRecent } from "../../hooks/useRecent.ts";
 import RecentModal from "../recent/RecentModal.tsx";
+import SettingModal from "../setting/SettingModal.tsx";
 
 /* ================= 类型 ================= */
 
@@ -106,6 +108,7 @@ const Widget = ({ onSearch }: Props) => {
 
   const { data } = useRecent(currUser?.id, 5, 0);
   const [recentOpen, setRecentOpen] = useState(false);
+  const [openSetting, setOpenSetting] = useState(false);
 
   const recentList: MenuItem[] = data
     ? data?.data.rows.map((item, id) => {
@@ -142,7 +145,12 @@ const Widget = ({ onSearch }: Props) => {
       label: "巴利三藏",
       activeId: "workspace.tipitaka",
     },
-
+    {
+      key: "/workspace/setting",
+      icon: <SettingOutlined />,
+      label: "setting",
+      activeId: "workspace.setting",
+    },
     { type: "divider", key: "d1" },
 
     {
@@ -237,6 +245,9 @@ const Widget = ({ onSearch }: Props) => {
     } else if (key === "/workspace/recent/list") {
       setRecentOpen(true);
       return;
+    } else if (key === "/workspace/setting") {
+      setOpenSetting(true);
+      return;
     }
     navigate(key);
   };
@@ -262,6 +273,7 @@ const Widget = ({ onSearch }: Props) => {
           }
         }}
       />
+      <SettingModal open={openSetting} onClose={() => setOpenSetting(false)} />
     </>
   );
 };

+ 16 - 0
dashboard-v6/src/components/setting/SettingDict.tsx

@@ -0,0 +1,16 @@
+import { useAppSelector } from "../../hooks";
+import { settingInfo } from "../../reducers/setting";
+
+import { SettingFind } from "./default";
+import SettingItem from "./SettingItem";
+
+const SettingDict = () => {
+  const settings = useAppSelector(settingInfo);
+  return (
+    <div>
+      <SettingItem data={SettingFind("setting.dict.lang", settings)} />
+    </div>
+  );
+};
+
+export default SettingDict;

+ 2 - 16
dashboard-v6/src/components/setting/SettingArticle.tsx → dashboard-v6/src/components/setting/SettingEditor.tsx

@@ -6,7 +6,7 @@ import { SettingFind } from "./default";
 import SettingItem from "./SettingItem";
 import { useIntl } from "react-intl";
 
-const SettingArticleWidget = () => {
+const SettingEditor = () => {
   const settings = useAppSelector(settingInfo);
   const intl = useIntl();
   return (
@@ -40,22 +40,8 @@ const SettingArticleWidget = () => {
         })}
       </Divider>
       <SettingItem data={SettingFind("setting.wbw.order", settings)} />
-      <Divider>Nissaya</Divider>
-      <SettingItem
-        data={SettingFind("setting.nissaya.layout.read", settings)}
-      />
-      <SettingItem
-        data={SettingFind("setting.nissaya.layout.edit", settings)}
-      />
-
-      <Divider>
-        {intl.formatMessage({
-          id: `columns.library.dict.title`,
-        })}
-      </Divider>
-      <SettingItem data={SettingFind("setting.dict.lang", settings)} />
     </div>
   );
 };
 
-export default SettingArticleWidget;
+export default SettingEditor;

+ 19 - 5
dashboard-v6/src/components/setting/SettingModal.tsx

@@ -1,14 +1,19 @@
 import { Modal, Tabs } from "antd";
 import { useState } from "react";
-import SettingArticle from "./SettingArticle";
 import SettingAccount from "./SettingAccount";
+import { useIntl } from "react-intl";
+import SettingNissaya from "./SettingNissaya";
+import SettingDict from "./SettingDict";
+import SettingEditor from "./SettingEditor";
 interface IWidget {
   trigger?: React.ReactNode;
   open?: boolean;
   onClose?: (isOpen: boolean) => void;
 }
-const SettingModalWidget = ({ trigger, open, onClose }: IWidget) => {
+const SettingModal = ({ trigger, open, onClose }: IWidget) => {
   const [isInnerOpen, setIsInnerOpen] = useState(false);
+  const intl = useIntl();
+
   const isModalOpen = open ?? isInnerOpen;
 
   const showModal = () => {
@@ -38,12 +43,21 @@ const SettingModalWidget = ({ trigger, open, onClose }: IWidget) => {
         open={isModalOpen}
         onOk={handleOk}
         onCancel={handleCancel}
+        style={{ top: 10, maxWidth: 600 }}
       >
         <Tabs
-          tabPlacement="start"
+          tabPlacement="top"
           items={[
             { label: "账户", key: "account", children: <SettingAccount /> }, // 务必填写 key
-            { label: "编辑器", key: "editor", children: <SettingArticle /> },
+            { label: "编辑器", key: "editor", children: <SettingEditor /> },
+            { label: "Nissaya", key: "nissaya", children: <SettingNissaya /> },
+            {
+              label: intl.formatMessage({
+                id: `columns.library.dict.title`,
+              }),
+              key: "dict",
+              children: <SettingDict />,
+            },
           ]}
         />
       </Modal>
@@ -51,4 +65,4 @@ const SettingModalWidget = ({ trigger, open, onClose }: IWidget) => {
   );
 };
 
-export default SettingModalWidget;
+export default SettingModal;

+ 22 - 0
dashboard-v6/src/components/setting/SettingNissaya.tsx

@@ -0,0 +1,22 @@
+import { useAppSelector } from "../../hooks";
+import { settingInfo } from "../../reducers/setting";
+
+import { SettingFind } from "./default";
+import SettingItem from "./SettingItem";
+
+const SettingNissaya = () => {
+  const settings = useAppSelector(settingInfo);
+
+  return (
+    <div>
+      <SettingItem
+        data={SettingFind("setting.nissaya.layout.read", settings)}
+      />
+      <SettingItem
+        data={SettingFind("setting.nissaya.layout.edit", settings)}
+      />
+    </div>
+  );
+};
+
+export default SettingNissaya;

+ 22 - 0
dashboard-v6/src/components/tipitaka/components/CommentrayPad.tsx

@@ -0,0 +1,22 @@
+import type { ReactNode } from "react";
+
+interface IWidget {
+  children?: ReactNode | ReactNode[];
+}
+const CommentaryPad = ({ children }: IWidget) => {
+  return (
+    <div
+      style={{
+        border: "2px dotted darkred",
+        borderRadius: 8,
+        padding: 4,
+        margin: 6,
+        backgroundColor: "#f5deb357",
+      }}
+    >
+      {children}
+    </div>
+  );
+};
+
+export default CommentaryPad;

+ 32 - 5
dashboard-v6/src/components/tipitaka/components/ParagraphRead.tsx

@@ -1,11 +1,18 @@
+import { Flex } from "antd";
+import { useSetting } from "../../../hooks/useSetting";
 import MdOrigin from "../../sentence/components/MdOrigin";
 import MdTranslation from "../../sentence/components/MdTranslation";
 import type { IWidgetSentEditInner } from "../../sentence/SentEdit";
+import CommentaryPad from "./CommentrayPad";
 
 interface IWidget {
   data?: IWidgetSentEditInner[];
 }
 const ParagraphRead = ({ data }: IWidget) => {
+  const direction = useSetting("setting.layout.direction");
+  const layoutCommentary = useSetting("setting.layout.commentary");
+  console.debug("direction", direction);
+
   const channels: string[] = [];
   data?.forEach((value) => {
     value.translation?.forEach((trans) => {
@@ -14,9 +21,19 @@ const ParagraphRead = ({ data }: IWidget) => {
       }
     });
   });
+
+  let commentaries: number = 0;
+  data?.forEach((value) => {
+    value.commentaries?.forEach((comm) => {
+      if (comm.content && comm.content?.length > 0) {
+        commentaries++;
+      }
+    });
+  });
+
   return (
-    <div>
-      <div style={{ display: "flex" }}>
+    <Flex vertical={layoutCommentary === "row"}>
+      <Flex gap="middle" vertical={direction === "row"} style={{ flex: 5 }}>
         {/**原文区 */}
         <div className="sent_read" style={{ flex: 5, padding: 4 }}>
           {data?.map((item) => {
@@ -46,10 +63,20 @@ const ParagraphRead = ({ data }: IWidget) => {
             );
           })}
         </div>
-      </div>
+      </Flex>
       {/**注疏区 */}
-      <div></div>
-    </div>
+      {commentaries > 0 && (
+        <div style={{ flex: 5 }}>
+          <CommentaryPad>
+            {data?.map((item) => {
+              return item.commentaries?.map((item, id) => {
+                return <MdTranslation text={item.html} key={id} />;
+              });
+            })}
+          </CommentaryPad>
+        </div>
+      )}
+    </Flex>
   );
 };