ExportMobileHeading.php 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Models\PaliText;
  4. use Illuminate\Console\Command;
  5. use Illuminate\Support\Facades\DB;
  6. /**
  7. * 导出移动端离线目录库(SQLite)。
  8. *
  9. * 替代 wikipali-mobile 仓库里的 src/data/tipitaka_heading.json(5.5MB,
  10. * 全量载入内存)。导出 pali_texts 全部段落(不只是章节行,下载功能需要
  11. * 非章节段落的字符数),除原有目录字段外额外带上:
  12. * - length:段落字符数(源表列名 `lenght` 为拼写错误,导出时纠正)
  13. *
  14. * - tags:pali_texts.uid 经 tag_maps / tags 取到的标签名,逗号分隔
  15. * - cs_para + book_name:来自 related_paragraphs,用于由段落定位对应的
  16. * 义注 / 复注章节起始位置(已合并进同一张表,输出只有 pali_text 一张表)
  17. */
  18. class ExportMobileHeading extends Command
  19. {
  20. protected $signature = 'export:mobile.heading
  21. {--out= : 输出文件路径,默认 storage/app/public/export/mobile/tipitaka-heading-<date>.db3}
  22. {--copy-to= : 导出后额外复制一份到该路径(例如移动端仓库的 assets/db/tipitaka.db3)}';
  23. protected $description = '导出移动端离线目录 SQLite(含 tags 与义注复注关联段落)';
  24. public function handle(): int
  25. {
  26. $out = $this->option('out');
  27. if (! $out) {
  28. $dir = storage_path('app/public/export/mobile');
  29. if (! is_dir($dir)) {
  30. mkdir($dir, 0775, true);
  31. }
  32. $out = $dir.'/tipitaka-heading-'.date('Y-m-d').'.db3';
  33. }
  34. if (file_exists($out)) {
  35. unlink($out);
  36. }
  37. $dbh = new \PDO('sqlite:'.$out, '', '', [\PDO::ATTR_PERSISTENT => true]);
  38. $dbh->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
  39. $this->createSchema($dbh);
  40. $texts = $this->exportPaliTexts($dbh);
  41. $this->writeMeta($dbh, [
  42. 'generated_at' => date('c'),
  43. 'source' => config('app.url', ''),
  44. 'pali_text_rows' => (string) $texts,
  45. ]);
  46. // 建索引放在插入之后,避免边插边维护索引
  47. $this->createIndexes($dbh);
  48. $dbh->exec('VACUUM');
  49. $dbh = null;
  50. $this->newLine();
  51. $this->info(sprintf(
  52. '导出完成:%s(%s,pali_text %d 行)',
  53. $out,
  54. $this->humanSize(filesize($out)),
  55. $texts
  56. ));
  57. $copyTo = $this->option('copy-to');
  58. if ($copyTo) {
  59. $dir = dirname($copyTo);
  60. if (! is_dir($dir)) {
  61. mkdir($dir, 0775, true);
  62. }
  63. copy($out, $copyTo);
  64. $this->info('已复制到:'.$copyTo);
  65. }
  66. return 0;
  67. }
  68. private function createSchema(\PDO $dbh): void
  69. {
  70. // 全量段落(不只是章节标题行):下载功能需要非章节段落的字符数。
  71. // 关联注释书(cs_para / book_name)已合并进本表 —— 每个段落至多对应
  72. // 一部注释书,无对应时为 NULL。
  73. $dbh->exec('CREATE TABLE pali_text (
  74. book INTEGER NOT NULL,
  75. paragraph INTEGER NOT NULL,
  76. level INTEGER NOT NULL,
  77. toc TEXT,
  78. length INTEGER,
  79. chapter_len INTEGER,
  80. chapter_strlen INTEGER,
  81. parent INTEGER,
  82. tags TEXT,
  83. cs_para INTEGER,
  84. book_name TEXT,
  85. PRIMARY KEY (book, paragraph)
  86. )');
  87. $dbh->exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)');
  88. }
  89. private function createIndexes(\PDO $dbh): void
  90. {
  91. $dbh->exec('CREATE INDEX idx_pali_text_book_level ON pali_text (book, level)');
  92. $dbh->exec('CREATE INDEX idx_pali_text_parent ON pali_text (book, parent)');
  93. $dbh->exec('CREATE INDEX idx_pali_text_related ON pali_text (book_name, cs_para)');
  94. }
  95. private function exportPaliTexts(\PDO $dbh): int
  96. {
  97. $total = PaliText::count();
  98. $this->line("导出 pali_text(全量段落):{$total} 行");
  99. $bar = $this->output->createProgressBar($total);
  100. $bar->setRedrawFrequency(5000);
  101. // uid 只用于 join 标签,不写入结果表
  102. $tagsByUid = $this->loadTags();
  103. $related = $this->loadRelated();
  104. $stmt = $dbh->prepare('INSERT INTO pali_text
  105. (book, paragraph, level, toc, length, chapter_len, chapter_strlen,
  106. parent, tags, cs_para, book_name)
  107. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
  108. $dbh->beginTransaction();
  109. $n = 0;
  110. foreach (
  111. PaliText::select(['uid', 'book', 'paragraph', 'level', 'toc',
  112. 'lenght', 'chapter_len', 'chapter_strlen', 'parent'])
  113. ->orderBy('book')
  114. ->orderBy('paragraph')
  115. ->cursor() as $row
  116. ) {
  117. $rel = $related[$row->book.'-'.$row->paragraph] ?? null;
  118. $stmt->execute([
  119. $row->book,
  120. $row->paragraph,
  121. $row->level,
  122. $row->toc !== '' ? $row->toc : null,
  123. // 源表列名 `lenght` 是拼写错误,导出时纠正为 `length`
  124. $row->lenght,
  125. $row->chapter_len,
  126. $row->chapter_strlen,
  127. $row->parent,
  128. isset($tagsByUid[$row->uid]) ? implode(',', $tagsByUid[$row->uid]) : null,
  129. $rel[0] ?? null,
  130. $rel[1] ?? null,
  131. ]);
  132. $n++;
  133. $bar->advance();
  134. }
  135. $dbh->commit();
  136. $bar->finish();
  137. $this->newLine();
  138. return $n;
  139. }
  140. /**
  141. * 关联注释书:由段落 (book, para) 找到对应义注 / 复注的起始位置。
  142. *
  143. * related_paragraphs 中同一段落会有多行,映射到一段连续的 cs_para 区间,
  144. * 但 book_name 唯一,因此这里取 min(cs_para) 作为起始位置合并成一行。
  145. * book_name 为空表示该段落没有对应的注释书,直接跳过(结果列留 NULL)。
  146. *
  147. * @return array<string, array{0:int,1:string}> "book-para" => [cs_para, book_name]
  148. */
  149. private function loadRelated(): array
  150. {
  151. $this->line('载入 related_paragraphs …');
  152. $out = [];
  153. DB::table('related_paragraphs')
  154. ->select(['book', 'para', 'book_name', DB::raw('min(cs_para) as cs_para')])
  155. ->whereNotNull('book_name')
  156. ->where('book_name', '<>', '')
  157. ->groupBy('book', 'para', 'book_name')
  158. ->orderBy('book')
  159. ->orderBy('para')
  160. ->chunk(50000, function ($rows) use (&$out) {
  161. foreach ($rows as $r) {
  162. $out[$r->book.'-'.$r->para] = [(int) $r->cs_para, $r->book_name];
  163. }
  164. });
  165. $this->line(' 有注释书的段落:'.count($out));
  166. return $out;
  167. }
  168. /** @return array<string, string[]> uid => tag names */
  169. private function loadTags(): array
  170. {
  171. $this->line('载入 tag_maps / tags …');
  172. $out = [];
  173. DB::table('tag_maps')
  174. ->join('tags', 'tags.id', '=', 'tag_maps.tag_id')
  175. ->where('tag_maps.table_name', 'pali_texts')
  176. ->select(['tag_maps.anchor_id', 'tags.name'])
  177. ->orderBy('tag_maps.anchor_id')
  178. ->chunk(20000, function ($rows) use (&$out) {
  179. foreach ($rows as $r) {
  180. $out[$r->anchor_id][] = $r->name;
  181. }
  182. });
  183. $this->line(' 标签锚点:'.count($out));
  184. return $out;
  185. }
  186. private function writeMeta(\PDO $dbh, array $meta): void
  187. {
  188. $stmt = $dbh->prepare('INSERT INTO meta (key, value) VALUES (?, ?)');
  189. foreach ($meta as $k => $v) {
  190. $stmt->execute([$k, $v]);
  191. }
  192. }
  193. private function humanSize(int $bytes): string
  194. {
  195. $units = ['B', 'KB', 'MB', 'GB'];
  196. $i = 0;
  197. while ($bytes >= 1024 && $i < count($units) - 1) {
  198. $bytes /= 1024;
  199. $i++;
  200. }
  201. return round($bytes, 1).' '.$units[$i];
  202. }
  203. }