TipitakaReadChapterController.php 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. <?php
  2. namespace App\Http\Controllers;
  3. use App\Models\PaliText;
  4. use App\Services\PaliContentService;
  5. use Illuminate\Http\JsonResponse;
  6. use Illuminate\Http\Request;
  7. use Illuminate\Support\Str;
  8. class TipitakaReadChapterController extends Controller
  9. {
  10. /**
  11. * 阅读模式章节内容。输入章节起始 book/para,按 pagesize 分批返回段落。
  12. *
  13. * pagesize 两种写法:
  14. * - `20000b` 按字节,累加 pali_texts.lenght 直到超过上限(每页至少一段)
  15. * - `10p` 按段落数
  16. */
  17. public function index(Request $request, PaliContentService $paliService): JsonResponse
  18. {
  19. $data = $request->validate([
  20. 'book' => 'required|integer',
  21. 'para' => 'required|integer',
  22. 'channel' => 'required|uuid',
  23. 'format' => 'string|in:html,markdown,react,text',
  24. 'view' => 'string|in:display,sentences,all',
  25. 'pagesize' => ['string', 'regex:/^\d+[bp]$/'],
  26. 'page' => 'integer|min:1',
  27. ]);
  28. return $this->chapter(
  29. (int) $data['book'],
  30. (int) $data['para'],
  31. $data['channel'],
  32. $data,
  33. $paliService
  34. );
  35. }
  36. /**
  37. * 同 index,id 格式 {book}-{para}
  38. */
  39. public function show(Request $request, string $id, PaliContentService $paliService): JsonResponse
  40. {
  41. $arrId = explode('-', $id);
  42. if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
  43. return $this->error('invalid id');
  44. }
  45. $channel = $request->input('channel');
  46. if (! Str::isUuid($channel)) {
  47. return $this->error('invalid channel');
  48. }
  49. return $this->chapter(
  50. (int) $arrId[0],
  51. (int) $arrId[1],
  52. $channel,
  53. $request->only(['format', 'view', 'pagesize', 'page']),
  54. $paliService
  55. );
  56. }
  57. /**
  58. * @param array{format?: string, view?: string, pagesize?: string, page?: int|string} $param
  59. */
  60. protected function chapter(
  61. int $book,
  62. int $para,
  63. string $channel,
  64. array $param,
  65. PaliContentService $paliService
  66. ): JsonResponse {
  67. $format = $param['format'] ?? 'html';
  68. $view = $param['view'] ?? 'display';
  69. $pageSize = $param['pagesize'] ?? '10p';
  70. $page = max(1, (int) ($param['page'] ?? 1));
  71. $chapter = PaliText::where('book', $book)->where('paragraph', $para)->first();
  72. if (! $chapter) {
  73. return $this->error('chapter not found');
  74. }
  75. $to = $para + max(1, (int) $chapter->chapter_len) - 1;
  76. /** @var array<int, array{paragraph: int, lenght: int}> $paragraphs */
  77. $paragraphs = PaliText::where('book', $book)
  78. ->whereBetween('paragraph', [$para, $to])
  79. ->orderBy('paragraph')
  80. ->get(['paragraph', 'lenght'])
  81. ->all();
  82. $total = count($paragraphs);
  83. if ($total === 0) {
  84. return $this->error('chapter is empty');
  85. }
  86. $slice = $this->slice($paragraphs, $pageSize, $page);
  87. if ($slice === null) {
  88. return $this->error('page out of range');
  89. }
  90. $items = [];
  91. foreach ($slice as $row) {
  92. $paragraph = $paliService->readParagraph($book, (int) $row->paragraph, $channel, $format);
  93. if (empty($paragraph['display'])) {
  94. continue;
  95. }
  96. $items[] = $this->filterView($paragraph, $view);
  97. }
  98. $first = $slice[0]->paragraph;
  99. $last = $slice[count($slice) - 1]->paragraph;
  100. return $this->ok([
  101. 'items' => $items,
  102. 'pagination' => [
  103. 'page' => $page,
  104. 'pageSize' => $pageSize,
  105. 'total' => $total,
  106. 'book' => $book,
  107. 'from' => (int) $first,
  108. 'to' => (int) $last,
  109. 'hasMore' => $last < $paragraphs[$total - 1]->paragraph,
  110. ],
  111. ]);
  112. }
  113. /**
  114. * 取第 $page 批段落。按段落数时直接切片,按字节时从头累加 lenght 逐页推进。
  115. *
  116. * @param array<int, PaliText> $paragraphs
  117. * @return array<int, PaliText>|null 页码越界时返回 null
  118. */
  119. protected function slice(array $paragraphs, string $pageSize, int $page): ?array
  120. {
  121. $limit = (int) substr($pageSize, 0, -1);
  122. $unit = substr($pageSize, -1);
  123. if ($limit < 1) {
  124. return null;
  125. }
  126. if ($unit === 'p') {
  127. $offset = ($page - 1) * $limit;
  128. $slice = array_slice($paragraphs, $offset, $limit);
  129. return $slice === [] ? null : $slice;
  130. }
  131. // 字节模式:每页累加 lenght,超过上限即断页,每页至少一段
  132. $offset = 0;
  133. $count = count($paragraphs);
  134. for ($current = 1; $offset < $count; $current++) {
  135. $bytes = 0;
  136. $take = 0;
  137. while ($offset + $take < $count) {
  138. $bytes += (int) $paragraphs[$offset + $take]->lenght;
  139. $take++;
  140. if ($bytes >= $limit) {
  141. break;
  142. }
  143. }
  144. if ($current === $page) {
  145. return array_slice($paragraphs, $offset, $take);
  146. }
  147. $offset += $take;
  148. }
  149. return null;
  150. }
  151. /**
  152. * 按 view 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。
  153. *
  154. * @param array{para: int, display: string, sentences: array} $paragraph
  155. */
  156. protected function filterView(array $paragraph, string $view): array
  157. {
  158. return match ($view) {
  159. 'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']],
  160. 'all' => $paragraph,
  161. default => ['para' => $paragraph['para'], 'display' => $paragraph['display']],
  162. };
  163. }
  164. }