ExportMobileHeading.php 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  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. * 全量载入内存)。除原有目录字段外,额外带上:
  11. *
  12. * - tags:pali_texts.uid 经 tag_maps / tags 取到的标签名,逗号分隔
  13. * - related_paragraphs:cs_para + book_name,用于由根本章节定位
  14. * 对应的义注 / 复注章节起始位置
  15. */
  16. class ExportMobileHeading extends Command
  17. {
  18. protected $signature = 'export:mobile.heading
  19. {--out= : 输出文件路径,默认 storage/app/public/export/mobile/tipitaka-heading-<date>.db3}
  20. {--max-level=7 : 只导出 level <= 该值的行(与现有 tipitaka_heading.json 口径一致)}
  21. {--copy-to= : 导出后额外复制一份到该路径(例如移动端仓库的 assets/db/tipitaka.db3)}';
  22. protected $description = '导出移动端离线目录 SQLite(含 tags 与义注复注关联段落)';
  23. public function handle(): int
  24. {
  25. $maxLevel = (int) $this->option('max-level');
  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. $headings = $this->exportHeadings($dbh, $maxLevel);
  41. $related = $this->exportRelated($dbh, $maxLevel);
  42. $this->writeMeta($dbh, [
  43. 'generated_at' => date('c'),
  44. 'source' => config('app.url', ''),
  45. 'max_level' => (string) $maxLevel,
  46. 'heading_rows' => (string) $headings,
  47. 'related_rows' => (string) $related,
  48. ]);
  49. // 建索引放在插入之后,避免边插边维护索引
  50. $this->createIndexes($dbh);
  51. $dbh->exec('VACUUM');
  52. $dbh = null;
  53. $this->newLine();
  54. $this->info(sprintf(
  55. '导出完成:%s(%s,heading %d 行,related %d 行)',
  56. $out,
  57. $this->humanSize(filesize($out)),
  58. $headings,
  59. $related
  60. ));
  61. $copyTo = $this->option('copy-to');
  62. if ($copyTo) {
  63. $dir = dirname($copyTo);
  64. if (! is_dir($dir)) {
  65. mkdir($dir, 0775, true);
  66. }
  67. copy($out, $copyTo);
  68. $this->info('已复制到:'.$copyTo);
  69. }
  70. return 0;
  71. }
  72. private function createSchema(\PDO $dbh): void
  73. {
  74. $dbh->exec('CREATE TABLE heading (
  75. book INTEGER NOT NULL,
  76. paragraph INTEGER NOT NULL,
  77. level INTEGER NOT NULL,
  78. toc TEXT,
  79. chapter_len INTEGER,
  80. chapter_strlen INTEGER,
  81. parent INTEGER,
  82. uid TEXT,
  83. tags TEXT,
  84. PRIMARY KEY (book, paragraph)
  85. )');
  86. // 一个根本章节可关联多部义注 / 复注,故独立成表
  87. $dbh->exec('CREATE TABLE related_paragraph (
  88. book INTEGER NOT NULL,
  89. para INTEGER NOT NULL,
  90. book_id INTEGER,
  91. cs_para INTEGER,
  92. book_name TEXT
  93. )');
  94. $dbh->exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)');
  95. }
  96. private function createIndexes(\PDO $dbh): void
  97. {
  98. $dbh->exec('CREATE INDEX idx_heading_book_level ON heading (book, level)');
  99. $dbh->exec('CREATE INDEX idx_heading_parent ON heading (book, parent)');
  100. $dbh->exec('CREATE INDEX idx_related_src ON related_paragraph (book, para)');
  101. $dbh->exec('CREATE INDEX idx_related_dst ON related_paragraph (book_name, cs_para)');
  102. }
  103. private function exportHeadings(\PDO $dbh, int $maxLevel): int
  104. {
  105. $total = PaliText::where('level', '<=', $maxLevel)->count();
  106. $this->line("导出 heading(level <= {$maxLevel}):{$total} 行");
  107. $bar = $this->output->createProgressBar($total);
  108. // uid -> 标签名列表。tag_maps.table_name 对 pali_texts 使用复数表名。
  109. $tagsByUid = $this->loadTags();
  110. $stmt = $dbh->prepare('INSERT INTO heading
  111. (book, paragraph, level, toc, chapter_len, chapter_strlen, parent, uid, tags)
  112. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
  113. $dbh->beginTransaction();
  114. $n = 0;
  115. foreach (
  116. PaliText::where('level', '<=', $maxLevel)
  117. ->select(['uid', 'book', 'paragraph', 'level', 'toc',
  118. 'chapter_len', 'chapter_strlen', 'parent'])
  119. ->orderBy('book')
  120. ->orderBy('paragraph')
  121. ->cursor() as $row
  122. ) {
  123. $stmt->execute([
  124. $row->book,
  125. $row->paragraph,
  126. $row->level,
  127. $row->toc,
  128. $row->chapter_len,
  129. $row->chapter_strlen,
  130. $row->parent,
  131. $row->uid,
  132. isset($tagsByUid[$row->uid]) ? implode(',', $tagsByUid[$row->uid]) : null,
  133. ]);
  134. $n++;
  135. $bar->advance();
  136. }
  137. $dbh->commit();
  138. $bar->finish();
  139. $this->newLine();
  140. return $n;
  141. }
  142. /** @return array<string, string[]> uid => tag names */
  143. private function loadTags(): array
  144. {
  145. $this->line('载入 tag_maps / tags …');
  146. $out = [];
  147. DB::table('tag_maps')
  148. ->join('tags', 'tags.id', '=', 'tag_maps.tag_id')
  149. ->where('tag_maps.table_name', 'pali_texts')
  150. ->select(['tag_maps.anchor_id', 'tags.name'])
  151. ->orderBy('tag_maps.anchor_id')
  152. ->chunk(20000, function ($rows) use (&$out) {
  153. foreach ($rows as $r) {
  154. $out[$r->anchor_id][] = $r->name;
  155. }
  156. });
  157. $this->line(' 标签锚点:'.count($out));
  158. return $out;
  159. }
  160. /**
  161. * 只导出「源段落本身是被导出的 heading」的关联行 ——
  162. * 移动端是按章节查关联,非章节段落的关联行用不到。
  163. */
  164. private function exportRelated(\PDO $dbh, int $maxLevel): int
  165. {
  166. $sub = DB::table('pali_texts')
  167. ->select(['book', 'paragraph'])
  168. ->where('level', '<=', $maxLevel);
  169. $query = DB::table('related_paragraphs as r')
  170. ->joinSub($sub, 'h', function ($join) {
  171. $join->on('h.book', '=', 'r.book')->on('h.paragraph', '=', 'r.para');
  172. })
  173. ->select(['r.book', 'r.para', 'r.book_id', 'r.cs_para', 'r.book_name']);
  174. $total = $query->count();
  175. $this->line("导出 related_paragraph:{$total} 行");
  176. $bar = $this->output->createProgressBar($total);
  177. $stmt = $dbh->prepare('INSERT INTO related_paragraph
  178. (book, para, book_id, cs_para, book_name) VALUES (?, ?, ?, ?, ?)');
  179. $dbh->beginTransaction();
  180. $n = 0;
  181. foreach ($query->orderBy('r.book')->orderBy('r.para')->cursor() as $row) {
  182. $stmt->execute([$row->book, $row->para, $row->book_id, $row->cs_para, $row->book_name]);
  183. $n++;
  184. $bar->advance();
  185. }
  186. $dbh->commit();
  187. $bar->finish();
  188. $this->newLine();
  189. return $n;
  190. }
  191. private function writeMeta(\PDO $dbh, array $meta): void
  192. {
  193. $stmt = $dbh->prepare('INSERT INTO meta (key, value) VALUES (?, ?)');
  194. foreach ($meta as $k => $v) {
  195. $stmt->execute([$k, $v]);
  196. }
  197. }
  198. private function humanSize(int $bytes): string
  199. {
  200. $units = ['B', 'KB', 'MB', 'GB'];
  201. $i = 0;
  202. while ($bytes >= 1024 && $i < count($units) - 1) {
  203. $bytes /= 1024;
  204. $i++;
  205. }
  206. return round($bytes, 1).' '.$units[$i];
  207. }
  208. }