Преглед изворни кода

Merge pull request #2467 from visuddhinanda/development

Development
visuddhinanda пре 3 дана
родитељ
комит
ec73ea9f8c

+ 2 - 2
api-v13/app/Console/Commands/MqExport.php

@@ -43,7 +43,7 @@ class MqExport extends Command
         $exchange = 'router';
         $queue = 'export';
         $this->info(" [*] Waiting for {$queue}. To exit press CTRL+C");
-        Log::debug('mq:progress start.');
+        Log::debug('mq:export start.');
         Mq::worker($exchange, $queue, function ($message) {
             $data = [
                 'book' => $message->book,
@@ -60,7 +60,7 @@ class MqExport extends Command
             }
             $ok = $this->call('export:chapter', $data);
             if ($ok !== 0) {
-                Log::error('mq:progress upgrade:progress fail', $data);
+                Log::error('mq:export export:chapter fail', $data);
             } else {
                 $this->info('Received book='.$message->book.' result='.$ok);
                 Log::debug('mq:export: done ', $data);

+ 1 - 1
api-v13/app/Console/Commands/MqExportPaliChapter.php

@@ -63,7 +63,7 @@ class MqExportPaliChapter extends Command
             }
             $ok = $this->call('export:chapter', $data);
             if ($ok !== 0) {
-                Log::error('mq:export.pali.chapter upgrade:progress fail', $data);
+                Log::error('mq:export.pali.chapter export:chapter fail', $data);
             } else {
                 $this->info('Received book='.$message->book.' result='.$ok);
                 Log::debug('mq:export.pali.chapter done ', $data);

+ 2 - 2
api-v13/app/Console/Commands/MqProgress.php

@@ -54,9 +54,9 @@ class MqProgress extends Command
                 '--para' => $message->para,
                 '--channel' => $message->channel,
             ];
-            $ok1 = $this->call('upgrade:progress', $data);
+            $ok1 = $this->call('upgrade:progress.para', $data);
             if ($ok1 !== 0) {
-                Log::error('mq:progress upgrade:progress fail', $data);
+                Log::error('mq:progress upgrade:progress.para fail', $data);
             }
             $ok2 = $this->call('upgrade:progress.chapter', $data);
             if ($ok2 !== 0) {

+ 567 - 0
api-v13/app/Console/Commands/TestMqProgress.php

@@ -0,0 +1,567 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Channel;
+use App\Models\PaliSentence;
+use App\Models\Progress;
+use App\Models\ProgressChapter;
+use App\Services\AuthService;
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+use Illuminate\Console\Attributes\Description;
+use Illuminate\Console\Attributes\Signature;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Http;
+use Symfony\Component\Process\Process;
+
+#[Signature('test:mq-progress {--book=93} {--channel=} {--limit=} {--wait=1} {--base-url=} {--mq-log=} {--no-start} {--keep} {--show-every=10} {--drain-timeout=14400}')]
+#[Description('Loop-test mq:progress by writing one sentence translation per API call and monitoring MQ output, logs and DB tables')]
+class TestMqProgress extends Command
+{
+    /** worker 输出 / 日志里需要高亮为异常的关键词 */
+    private const ERROR_MARKERS = [
+        'ambiguous',
+        'Command "',
+        'is not defined',
+        'exception',
+        'Exception',
+        'SQLSTATE',
+        'fail',
+        'task error',
+    ];
+
+    /** laravel.log 里值得展示的关键词(其余 debug/info 噪音折叠为计数) */
+    private const LARAVEL_RELEVANT = [
+        'mq:progress',
+        'upgrade:progress',
+        'mq worker',
+        'received message',
+        'mq done',
+        'ERROR',
+        'exception',
+        'ambiguous',
+        'Command "',
+        'SQLSTATE',
+        'fail',
+    ];
+
+    private string $mqLogPath = '';
+
+    private int $mqLogOffset = 0;
+
+    private int $laravelLogOffset = 0;
+
+    private int $laravelSuppressed = 0;
+
+    public function handle(): int
+    {
+        $book = (int) ($this->option('book') ?: 93);
+        $channelUid = $this->option('channel');
+        $limit = $this->option('limit') !== null && $this->option('limit') !== '' ? (int) $this->option('limit') : null;
+        $wait = max(0, (int) ($this->option('wait') ?: 1));
+        $baseUrl = rtrim((string) ($this->option('base-url') ?: config('app.url')), '/');
+        $this->mqLogPath = (string) ($this->option('mq-log') ?: storage_path('logs/mq-progress-test.log'));
+        $showEvery = max(1, (int) ($this->option('show-every') ?: 10));
+        $drainTimeout = max(0, (int) ($this->option('drain-timeout') ?: 14400));
+        $noStart = (bool) $this->option('no-start');
+        $keep = (bool) $this->option('keep');
+
+        $token = trim((string) env('TESTING_TOKEN', ''));
+        $account = (string) env('TESTING_ACCOUNT', '');
+
+        if ($token === '') {
+            $this->error('TESTING_TOKEN is empty in .env');
+
+            return 1;
+        }
+
+        $accountUid = $this->decodeUid($token);
+        if (! $accountUid) {
+            $this->error('TESTING_TOKEN is invalid or expired');
+
+            return 1;
+        }
+
+        if (! $channelUid) {
+            $channelUid = $this->resolveChannel($accountUid);
+            if (! $channelUid) {
+                $this->error('No translation channel found for the account. Pass --channel=<uid>.');
+
+                return 1;
+            }
+            $this->warn("Auto-selected channel: {$channelUid}");
+        }
+
+        $this->newLine();
+        $this->info('== test:mq-progress ==');
+        $this->line("  account     : {$account} ({$accountUid})");
+        $this->line("  book        : {$book}");
+        $this->line("  channel     : {$channelUid}");
+        $this->line("  base url    : {$baseUrl}");
+        $this->line('  mq log      : '.$this->mqLogPath);
+        $this->line('  laravel log : '.storage_path('logs/laravel.log'));
+        $this->newLine();
+
+        // 先确认 token 有效,避免每一句都 401
+        $authResp = Http::timeout(15)->withToken($token)->get("{$baseUrl}/api/v2/auth/current");
+        if ($authResp->failed() || $authResp->json('ok') !== true) {
+            $this->error('auth check failed: HTTP '.$authResp->status().' '.mb_substr($authResp->body(), 0, 200));
+
+            return 1;
+        }
+        $this->info('auth check ok.');
+
+        $paliRows = PaliSentence::where('book', $book)
+            ->orderBy('paragraph')
+            ->orderBy('word_begin')
+            ->get();
+        $total = $paliRows->count();
+        if ($limit !== null) {
+            $paliRows = $paliRows->take($limit);
+        }
+        $this->info("pali_sentences book={$book}: {$total}".($limit !== null ? " (writing first {$paliRows->count()})" : ''));
+
+        if ($paliRows->isEmpty()) {
+            $this->error('no pali sentences found');
+
+            return 1;
+        }
+
+        $before = $this->snapshot($book, $channelUid);
+        $this->info('DB snapshot (before):');
+        $this->table(['metric', 'value'], $this->snapshotRows($before));
+        $this->newLine();
+
+        // 重置 mq 输出文件并记录 laravel.log 当前位置
+        $this->resetFile($this->mqLogPath);
+        $this->mqLogOffset = 0;
+        $this->laravelLogOffset = $this->fileSize(storage_path('logs/laravel.log'));
+        $this->laravelSuppressed = 0;
+
+        $process = null;
+        if (! $noStart) {
+            $process = $this->startWorker();
+            $this->info('mq:progress started (pid='.$process->getPid().'), waiting for queue...');
+            sleep(2);
+            $this->flushMqLog($process);
+        } else {
+            $this->warn('--no-start: assuming mq:progress is already running');
+        }
+
+        $url = "{$baseUrl}/api/v2/sentence";
+        $okCount = 0;
+        $failCount = 0;
+        $lastFingerprint = $before['fingerprint'];
+        $mqErrors = 0;
+
+        $this->newLine();
+        $this->info("Start writing {$paliRows->count()} sentences (one POST each)...");
+        $this->newLine();
+
+        foreach ($paliRows as $i => $pali) {
+            $idx = $i + 1;
+            $content = mb_substr((string) $pali->text, 0, 20, 'UTF-8');
+            if ($content === '') {
+                $this->warn("[{$idx}] skip empty text para={$pali->paragraph} word_begin={$pali->word_begin}");
+
+                continue;
+            }
+
+            $payload = [
+                'channel' => $channelUid,
+                'sentences' => [[
+                    'book_id' => $book,
+                    'paragraph' => (int) $pali->paragraph,
+                    'word_start' => (int) $pali->word_begin,
+                    'word_end' => (int) $pali->word_end,
+                    'content' => $content,
+                ]],
+            ];
+
+            $status = 0;
+            $body = null;
+
+            try {
+                $resp = Http::timeout(30)->withToken($token)->post($url, $payload);
+                $status = $resp->status();
+                $body = $resp->json();
+                $written = $body['data']['count'] ?? null;
+                $ok = $resp->successful() && ($body['ok'] ?? false) === true;
+            } catch (\Throwable $e) {
+                $ok = false;
+                $written = null;
+                $this->error("[{$idx}] HTTP exception: {$e->getMessage()}");
+            }
+
+            if ($ok) {
+                $okCount++;
+                $this->line("[{$idx}/{$paliRows->count()}] para={$pali->paragraph} ws={$pali->word_begin} HTTP={$status} written={$written}");
+            } else {
+                $failCount++;
+                $this->error("[{$idx}] para={$pali->paragraph} ws={$pali->word_begin} HTTP={$status} FAILED ".mb_substr((string) json_encode($body, JSON_UNESCAPED_UNICODE), 0, 220));
+            }
+
+            if ($wait > 0) {
+                sleep($wait);
+            }
+
+            // 拉取 worker 控制台输出和 laravel.log 新内容
+            $mqErrors += $this->flushMqLog($process);
+            $mqErrors += $this->flushLaravelLog();
+
+            // 每 N 句报告一次 DB 指纹变化
+            if ($idx % $showEvery === 0 || $idx === $paliRows->count()) {
+                $snap = $this->snapshot($book, $channelUid);
+                if ($snap['fingerprint'] !== $lastFingerprint) {
+                    $lastFingerprint = $snap['fingerprint'];
+                    $this->line('  [db] progress_rows='.$snap['progress_count'].' all_strlen='.$snap['progress_sum']
+                        .' chapter_rows='.$snap['chapter_count'].' titles='.$snap['chapter_titles_count']);
+                }
+            }
+        }
+
+        // 等 worker 把队列全部消费完,保证最终 DB 快照准确
+        $received = $okCount;
+        if ($process !== null && $okCount > 0 && $drainTimeout > 0) {
+            [$received, $drainErrors] = $this->waitForWorkerToDrain($process, $okCount, $drainTimeout);
+            $mqErrors += $drainErrors;
+        }
+
+        sleep(1);
+        $mqErrors += $this->flushMqLog($process);
+        $mqErrors += $this->flushLaravelLog();
+
+        $this->newLine();
+        $this->info('== results ==');
+        $after = $this->snapshot($book, $channelUid);
+
+        $this->line('-- DB changes --');
+        $this->table(['metric', 'before', 'after', 'delta'], [
+            ['progress rows', (string) $before['progress_count'], (string) $after['progress_count'], (string) ($after['progress_count'] - $before['progress_count'])],
+            ['progress all_strlen sum', (string) $before['progress_sum'], (string) $after['progress_sum'], (string) ($after['progress_sum'] - $before['progress_sum'])],
+            ['progress_chapters rows', (string) $before['chapter_count'], (string) $after['chapter_count'], (string) ($after['chapter_count'] - $before['chapter_count'])],
+            ['progress_chapters titles', (string) $before['chapter_titles_count'], (string) $after['chapter_titles_count'], (string) ($after['chapter_titles_count'] - $before['chapter_titles_count'])],
+        ]);
+
+        $this->line('-- progress_chapters sample (title 应为写入内容的前 20 字符) --');
+        $samples = ProgressChapter::where('book', $book)
+            ->where('channel_id', $channelUid)
+            ->orderBy('para')
+            ->limit(10)
+            ->get(['para', 'title', 'progress', 'updated_at'])
+            ->toArray();
+        if ($samples) {
+            $this->table(
+                ['para', 'title', 'progress', 'updated_at'],
+                array_map(fn ($r) => [
+                    (string) $r['para'],
+                    mb_substr((string) $r['title'], 0, 30, 'UTF-8'),
+                    (string) $r['progress'],
+                    (string) $r['updated_at'],
+                ], $samples)
+            );
+        } else {
+            $this->line('  (no rows)');
+        }
+
+        $this->newLine();
+        $this->line('-- summary --');
+        $this->line("  api writes ok    : {$okCount}");
+        $this->line("  api writes failed: {$failCount}");
+        $this->line("  worker processed : {$received}/{$okCount}");
+        $this->line("  error lines (mq console + laravel.log): {$mqErrors}");
+        $this->line('  laravel.log suppressed noise: '.$this->laravelSuppressed);
+        $dbChanged = $after['fingerprint'] !== $before['fingerprint'];
+        $this->line('  db changed       : '.($dbChanged ? 'yes' : 'no'));
+
+        if ($okCount > 0 && ! $dbChanged) {
+            $this->error('⚠ 有写入成功但 progress / progress_chapters 完全没有变化 —— 说明 MqProgress 没有真正更新 DB(疑似 bug)。');
+        }
+
+        if ($process && ! $keep) {
+            $this->info('stopping mq:progress worker...');
+            $this->stopWorker($process);
+        } elseif ($keep) {
+            $this->warn('--keep: worker left running');
+        }
+
+        return $failCount === 0 && $mqErrors === 0 ? 0 : 1;
+    }
+
+    /** 从 token 解析账号 uid */
+    private function decodeUid(string $token): ?string
+    {
+        try {
+            $jwt = JWT::decode($token, new Key(AuthService::getJwtKey(), 'HS512'));
+
+            return $jwt->uid ?? null;
+        } catch (\Throwable $e) {
+            return null;
+        }
+    }
+
+    /** 默认选账号名下第一个 translation channel */
+    private function resolveChannel(string $accountUid): ?string
+    {
+        $candidates = Channel::where('owner_uid', $accountUid)
+            ->where('type', 'translation')
+            ->where('status', 30)
+            ->orderBy('name')
+            ->get(['uid', 'name']);
+
+        if ($candidates->isEmpty()) {
+            return null;
+        }
+        $this->line('candidate translation channels:');
+        foreach ($candidates as $c) {
+            $this->line("  - {$c->uid} ({$c->name})");
+        }
+
+        return $candidates->first()->uid;
+    }
+
+    private function snapshot(int $book, string $channelUid): array
+    {
+        $progressQuery = Progress::where('book', $book)->where('channel_id', $channelUid);
+        $chapterQuery = ProgressChapter::where('book', $book)->where('channel_id', $channelUid);
+
+        $snap = [
+            'progress_count' => (clone $progressQuery)->count(),
+            'progress_sum' => (int) (clone $progressQuery)->sum('all_strlen'),
+            'progress_max_updated_at' => (string) (clone $progressQuery)->max('updated_at'),
+            'chapter_count' => (clone $chapterQuery)->count(),
+            'chapter_titles_count' => (clone $chapterQuery)->whereNotNull('title')->where('title', '!=', '')->count(),
+            'chapter_max_updated_at' => (string) (clone $chapterQuery)->max('updated_at'),
+        ];
+        $snap['fingerprint'] = implode('|', [
+            $snap['progress_count'],
+            $snap['progress_sum'],
+            $snap['progress_max_updated_at'],
+            $snap['chapter_count'],
+            $snap['chapter_titles_count'],
+            $snap['chapter_max_updated_at'],
+        ]);
+
+        return $snap;
+    }
+
+    /** @return array<int, array{0: string, 1: string}> */
+    private function snapshotRows(array $snap): array
+    {
+        return [
+            ['progress rows', (string) $snap['progress_count']],
+            ['progress all_strlen sum', (string) $snap['progress_sum']],
+            ['progress max updated_at', $snap['progress_max_updated_at']],
+            ['progress_chapters rows', (string) $snap['chapter_count']],
+            ['progress_chapters titles', (string) $snap['chapter_titles_count']],
+            ['progress_chapters max updated_at', $snap['chapter_max_updated_at']],
+        ];
+    }
+
+    private function startWorker(): Process
+    {
+        $this->resetFile($this->mqLogPath);
+
+        // 数组形式会让 Symfony 自动加 `exec`,stop() 的信号能直接打到 php 进程,不会留下孤儿 worker。
+        $process = new Process(['php', 'artisan', 'mq:progress'], base_path(), null, null, null);
+        $process->setTimeout(null);
+        $process->start();
+
+        return $process;
+    }
+
+    /** 把 worker 管道里的增量输出转存到 mq 日志文件(stdout 行缓冲,管道可实时读到) */
+    private function drainWorker(Process $process): void
+    {
+        $out = $process->getIncrementalOutput();
+        $err = $process->getIncrementalErrorOutput();
+        if ($out !== '') {
+            file_put_contents($this->mqLogPath, $out, FILE_APPEND);
+        }
+        if ($err !== '') {
+            file_put_contents($this->mqLogPath, $err, FILE_APPEND);
+        }
+    }
+
+    /**
+     * 等待 worker 处理完 expected 条消息(以控制台里的 "Received book=" 行计数)。
+     *
+     * @return array{0: int, 1: int} [已处理条数, 期间命中的错误行数]
+     */
+    private function waitForWorkerToDrain(Process $process, int $expected, int $timeoutSeconds): array
+    {
+        $deadline = time() + $timeoutSeconds;
+        $lastLog = 0;
+        $received = 0;
+        $errors = 0;
+
+        while (true) {
+            $before = $this->fileSize($this->mqLogPath);
+            $this->drainWorker($process);
+            $after = $this->fileSize($this->mqLogPath);
+
+            if ($after > $before) {
+                $errors += $this->scanFileErrors($this->mqLogPath, $before, $after);
+                // 静默推进 offset,避免结束时把几千行 drain 内容刷屏
+                $this->mqLogOffset = $after;
+            }
+
+            $received = substr_count((string) @file_get_contents($this->mqLogPath), 'Received book=');
+            $now = time();
+
+            if ($received >= $expected) {
+                $this->info("worker drained all messages ({$received}/{$expected})");
+
+                break;
+            }
+            if (! $process->isRunning()) {
+                $this->warn("worker exited before draining ({$received}/{$expected})");
+
+                break;
+            }
+            if ($now >= $deadline) {
+                $this->warn("drain timeout after {$timeoutSeconds}s ({$received}/{$expected})");
+
+                break;
+            }
+            if ($now - $lastLog >= 30) {
+                $this->line("  [drain] worker processed {$received}/{$expected} messages, waiting...");
+                $lastLog = $now;
+            }
+
+            sleep(2);
+        }
+
+        return [$received, $errors];
+    }
+
+    /** 统计文件 [start,end) 字节区间内命中错误关键词的行数 */
+    private function scanFileErrors(string $path, int $start, int $end): int
+    {
+        $fh = @fopen($path, 'r');
+        if (! $fh) {
+            return 0;
+        }
+        fseek($fh, $start);
+        $content = (string) fread($fh, $end - $start);
+        fclose($fh);
+
+        $errors = 0;
+        foreach (preg_split('/\r\n|\r|\n/', $content) ?: [] as $line) {
+            if ($line !== '' && $this->hasErrorMarker($line)) {
+                $errors++;
+            }
+        }
+
+        return $errors;
+    }
+
+    private function stopWorker(Process $process): void
+    {
+        if (! $process->isRunning()) {
+            return;
+        }
+        try {
+            $process->stop(5, 15);
+        } catch (\Throwable $e) {
+            // 忽略超时,下面强制杀
+        }
+        if ($process->isRunning()) {
+            try {
+                $process->stop(0, 9);
+            } catch (\Throwable $e) {
+                // ignore
+            }
+        }
+    }
+
+    /** 输出 worker 控制台文件新内容,返回命中的错误行数 */
+    private function flushMqLog(?Process $process = null): int
+    {
+        if ($process !== null) {
+            $this->drainWorker($process);
+        }
+
+        return $this->flushFile($this->mqLogPath, $this->mqLogOffset, 'mq', true);
+    }
+
+    private function flushLaravelLog(): int
+    {
+        return $this->flushFile(storage_path('logs/laravel.log'), $this->laravelLogOffset, 'laravel', false);
+    }
+
+    /** @param  bool  $showAll  true=逐行全打印;false=只打印相关/错误行,其余折叠计数 */
+    private function flushFile(string $path, int &$offset, string $label, bool $showAll): int
+    {
+        $size = $this->fileSize($path);
+        if ($size <= $offset) {
+            return 0;
+        }
+        $fh = @fopen($path, 'r');
+        if (! $fh) {
+            return 0;
+        }
+        fseek($fh, $offset);
+        $content = (string) fread($fh, $size - $offset);
+        $offset = $size;
+        fclose($fh);
+
+        $errors = 0;
+        $lines = preg_split('/\r\n|\r|\n/', $content) ?: [];
+        foreach ($lines as $line) {
+            if ($line === '') {
+                continue;
+            }
+            $hasError = $this->hasErrorMarker($line);
+            if ($hasError) {
+                $errors++;
+            }
+            if ($showAll || $hasError || $this->hasRelevantMarker($line)) {
+                if ($hasError) {
+                    $this->error("  [{$label}] {$line}");
+                } else {
+                    $this->line("  <comment>[{$label}]</comment> {$line}");
+                }
+            } elseif ($label === 'laravel') {
+                $this->laravelSuppressed++;
+            }
+        }
+
+        return $errors;
+    }
+
+    private function hasErrorMarker(string $line): bool
+    {
+        foreach (self::ERROR_MARKERS as $marker) {
+            if (str_contains($line, $marker)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private function hasRelevantMarker(string $line): bool
+    {
+        foreach (self::LARAVEL_RELEVANT as $marker) {
+            if (str_contains($line, $marker)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private function resetFile(string $path): void
+    {
+        @file_put_contents($path, '');
+    }
+
+    private function fileSize(string $path): int
+    {
+        $size = @filesize($path);
+
+        return $size === false ? 0 : $size;
+    }
+}

+ 1 - 1
api-v13/app/Console/Commands/UpdateCorpus.php

@@ -78,7 +78,7 @@ class UpdateCorpus extends Command
                 $this->info("Directory processed: {$stats['processed']} records saved, {$stats['errors']} errors");
                 if ($this->option('es') && isset($stats['channels'])) {
                     foreach ($stats['channels'] as $key => $channelId) {
-                        $this->call('upgrade:progress', ['--channel' => $channelId]);
+                        $this->call('upgrade:progress.para', ['--channel' => $channelId]);
                         $this->call('upgrade:progress.chapter', ['--channel' => $channelId]);
                         $this->call('opensearch:index-tipitaka', [
                             'book' => 0,

+ 1 - 1
api-v13/app/Console/Commands/UpgradeWeekly.php

@@ -43,7 +43,7 @@ class UpgradeWeekly extends Command
         }
         $currTime = time();
         // 译文进度
-        $this->call('upgrade:progress');
+        $this->call('upgrade:progress.para');
         $time = time() - $currTime;
         $message = "progress time:{$time}; ";
         $this->info($message);

+ 31 - 6
api-v13/app/Http/Resources/SearchPaliWbwResource.php

@@ -4,12 +4,26 @@ namespace App\Http\Resources;
 
 use App\Models\PageNumber;
 use App\Models\PaliText;
+use App\Services\PaliSeriesesService;
 use Illuminate\Contracts\Support\Arrayable;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
 
 class SearchPaliWbwResource extends JsonResource
 {
+    /**
+     * page_numbers.type 单字母代号到缩写/名称的映射。
+     *
+     * @var array<string, string>
+     */
+    private const TYPE_ABBR = [
+        'M' => 'My',
+        'P' => 'PTS',
+        'V' => 'VRI',
+        'T' => 'Thai',
+        'O' => 'Other',
+    ];
+
     /**
      * Transform the resource into an array.
      *
@@ -29,7 +43,7 @@ class SearchPaliWbwResource extends JsonResource
             ->where('paragraph', $this->paragraph)
             ->first();
         if ($paliText) {
-            $data['path'] = json_decode($paliText->path);
+            $data['path'] = json_decode($paliText->path, true);
             if ($paliText->level < 100) {
                 $data['paliTitle'] = $paliText->toc;
                 $book = $this->book;
@@ -62,21 +76,32 @@ class SearchPaliWbwResource extends JsonResource
             $data['highlight'] = str_replace($keyWordsUpper, $keyReplace, $paliText->html);
         }
 
-        $pageNumbers = PageNumber::where('book', $this->book)
+        $series = app(PaliSeriesesService::class)->find((int) $this->book, (int) $this->paragraph);
+
+        $ref = PageNumber::where('book', $this->book)
             ->where('paragraph', $this->paragraph)
             ->orderBy('wid')
             ->get()
             ->unique('type')
             ->map(fn ($pageNumber) => [
-                'type' => $pageNumber->type,
+                'type' => self::TYPE_ABBR[$pageNumber->type] ?? $pageNumber->type,
                 'page' => $pageNumber->page,
+                'title' => match ($pageNumber->type) {
+                    'M' => $series['abbr_my'] ?? null,
+                    'P' => $series['abbr_pts'] ?? null,
+                    default => null,
+                },
             ])
             ->values()
             ->all();
 
-        if ($pageNumbers !== []) {
-            $data['ref'] = $pageNumbers;
-        }
+        $ref[] = [
+            'type' => 'WP',
+            'page' => $this->paragraph,
+            'title' => $series['abbr_wp'] ?? null,
+        ];
+
+        $data['ref'] = $ref;
 
         return $data;
     }

+ 8 - 0
api-v13/app/Providers/AppServiceProvider.php

@@ -2,6 +2,7 @@
 
 namespace App\Providers;
 
+use App\Services\PaliSeriesesService;
 use App\Services\RomanizeService;
 use App\Tools\QueryBuilderMacro;
 use App\View\Composers\BlogViewComposer;
@@ -48,6 +49,13 @@ class AppServiceProvider extends ServiceProvider
         |--------------------------------------------------------------------------
         */
         $this->app->singleton(RomanizeService::class);
+
+        /*
+        |--------------------------------------------------------------------------
+        | Pali Serieses Service
+        |--------------------------------------------------------------------------
+        */
+        $this->app->singleton(PaliSeriesesService::class);
     }
 
     /**

+ 46 - 0
api-v13/app/Services/PaliSeriesesService.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Services;
+
+class PaliSeriesesService
+{
+    /**
+     * 书缩写表:book => [起始段落 => ['abbr_my' => ..., 'abbr_pts' => ..., 'abbr_wp' => ...]]
+     *
+     * @var array<int, array<int, array{abbr_my: string, abbr_pts: string, abbr_wp: string}>>
+     */
+    private array $serieses;
+
+    public function __construct()
+    {
+        $this->serieses = require resource_path('data/pali_serieses.php');
+    }
+
+    /**
+     * 根据书号与段落号查找该段所属文本的书缩写。
+     *
+     * 缩写表以“段落起始号”为键,同一书内多个文本按起始段落递增排列;
+     * 这里取不大于目标段落的最大起始号对应条目。
+     *
+     * @return array{abbr_my: string, abbr_pts: string, abbr_wp: string}|null
+     */
+    public function find(int $book, int $paragraph): ?array
+    {
+        $entries = $this->serieses[$book] ?? null;
+
+        if (! is_array($entries)) {
+            return null;
+        }
+
+        $result = null;
+        foreach ($entries as $start => $abbr) {
+            if ($start > $paragraph) {
+                break;
+            }
+
+            $result = $abbr;
+        }
+
+        return $result;
+    }
+}

+ 1843 - 0
api-v13/resources/data/pali_serieses.php

@@ -0,0 +1,1843 @@
+<?php
+
+return [
+    1 => [
+        2 => [
+            'abbr_my' => 'namakkāra',
+            'abbr_pts' => 'Nam',
+            'abbr_wp' => 'namakkāra.',
+        ],
+        159 => [
+            'abbr_my' => 'namakkāra-ṭī',
+            'abbr_pts' => 'NamT',
+            'abbr_wp' => 'namakkāra.ṭī.',
+        ],
+    ],
+    2 => [
+        2 => [
+            'abbr_my' => 'mahāpaṇāmapāṭha',
+            'abbr_pts' => 'Mpp',
+            'abbr_wp' => 'mahāpaṇāma.',
+        ],
+        428 => [
+            'abbr_my' => 'tigumbacetiya thomanā',
+            'abbr_pts' => 'Tgc',
+            'abbr_wp' => 'tigumba.',
+        ],
+        479 => [
+            'abbr_my' => 'vāsamālinīkya',
+            'abbr_pts' => 'Vāsam',
+            'abbr_wp' => 'vāsamālinī.',
+        ],
+    ],
+    3 => [
+        2 => [
+            'abbr_my' => 'lakkhaṇāto',
+            'abbr_pts' => 'Lakkh',
+            'abbr_wp' => 'lakkhaṇā.',
+        ],
+    ],
+    4 => [
+        2 => [
+            'abbr_my' => 'suttavandanā',
+            'abbr_pts' => 'Sutv',
+            'abbr_wp' => 'suttavandanā.',
+        ],
+    ],
+    5 => [
+        2 => [
+            'abbr_my' => 'jinālaṅkāra',
+            'abbr_pts' => 'Jināl',
+            'abbr_wp' => 'jinālaṅkāra.',
+        ],
+    ],
+    6 => [
+        2 => [
+            'abbr_my' => 'kamalāñjali',
+            'abbr_pts' => 'Kamal',
+            'abbr_wp' => 'kamalāñjali.',
+        ],
+    ],
+    7 => [
+        2 => [
+            'abbr_my' => 'pajjamadhu',
+            'abbr_pts' => 'Pjm',
+            'abbr_wp' => 'pajjamadhu.',
+        ],
+    ],
+    8 => [
+        2 => [
+            'abbr_my' => 'buddhaguṇagāthāvalī',
+            'abbr_pts' => 'Bgg',
+            'abbr_wp' => 'buddhaguṇagāthāvalī.',
+        ],
+    ],
+    9 => [
+        2 => [
+            'abbr_my' => 'abhidhānappadīpikā-ṭī',
+            'abbr_pts' => 'AbhpT',
+            'abbr_wp' => 'abhidhāna.ṭī.',
+        ],
+    ],
+    10 => [
+        2 => [
+            'abbr_my' => 'subodhālaṅkāro',
+            'abbr_pts' => 'Subodh',
+            'abbr_wp' => 'subodhālaṅkāra.',
+        ],
+    ],
+    11 => [
+        2 => [
+            'abbr_my' => 'subodhālaṅkāra-ṭī',
+            'abbr_pts' => 'SubodhT',
+            'abbr_wp' => 'subodhālaṅkāra.ṭī.',
+        ],
+    ],
+    12 => [
+        2 => [
+            'abbr_my' => 'bālāvatāra',
+            'abbr_pts' => 'Bālāv',
+            'abbr_wp' => 'bālāvatāra.',
+        ],
+    ],
+    13 => [
+        2 => [
+            'abbr_my' => 'moggallānasuttapāṭho',
+            'abbr_pts' => 'Mogg',
+            'abbr_wp' => 'moggallāna.sutta.',
+        ],
+        1105 => [
+            'abbr_my' => 'moggallānabyākaraṇaṃ',
+            'abbr_pts' => 'Mogg',
+            'abbr_wp' => 'moggallāna.byākaraṇa.',
+        ],
+    ],
+    14 => [
+        2 => [
+            'abbr_my' => 'kaccāyanabyākaraṇaṃ',
+            'abbr_pts' => 'Kacc',
+            'abbr_wp' => 'kaccāyana.byākaraṇa.',
+        ],
+        715 => [
+            'abbr_my' => 'mahākaccāyanasaddāpāṭha',
+            'abbr_pts' => 'Kacc',
+            'abbr_wp' => 'mahākaccāyana.sadda.',
+        ],
+    ],
+    15 => [
+        2 => [
+            'abbr_my' => 'saddanītippakaraṇaṃ (padamālā)',
+            'abbr_pts' => 'Sadd',
+            'abbr_wp' => 'saddanīti.pada.',
+        ],
+    ],
+    16 => [
+        2 => [
+            'abbr_my' => 'saddanītippakaraṇaṃ (dhātumālā)',
+            'abbr_pts' => 'Sadd',
+            'abbr_wp' => 'saddanīti.dhātu.',
+        ],
+    ],
+    17 => [
+        2 => [
+            'abbr_my' => 'padarūpasiddhi',
+            'abbr_pts' => 'Rūp',
+            'abbr_wp' => 'padarūpasiddhi.',
+        ],
+    ],
+    18 => [
+        2 => [
+            'abbr_my' => 'moggallāna-pañcikā-ṭī',
+            'abbr_pts' => 'MoggPT',
+            'abbr_wp' => 'moggallāna.pañcikā.ṭī.',
+        ],
+    ],
+    19 => [
+        2 => [
+            'abbr_my' => 'payogasiddhi',
+            'abbr_pts' => 'Payog',
+            'abbr_wp' => 'payogasiddhi.',
+        ],
+    ],
+    20 => [
+        2 => [
+            'abbr_my' => 'vuttodayaṃ',
+            'abbr_pts' => 'Vutt',
+            'abbr_wp' => 'vuttodaya.',
+        ],
+    ],
+    21 => [
+        2 => [
+            'abbr_my' => 'abhidhānappadīpikā',
+            'abbr_pts' => 'Abhp',
+            'abbr_wp' => 'abhidhāna.',
+        ],
+    ],
+    22 => [
+        2 => [
+            'abbr_my' => 'niruttidīpanīpāṭha',
+            'abbr_pts' => 'Nirutt',
+            'abbr_wp' => 'niruttidīpanī.',
+        ],
+    ],
+    23 => [
+        2 => [
+            'abbr_my' => 'paramatthadīpanī',
+            'abbr_pts' => 'Pmd',
+            'abbr_wp' => 'paramatthadīpanī.',
+        ],
+    ],
+    24 => [
+        2 => [
+            'abbr_my' => 'anudīpanīpāṭha',
+            'abbr_pts' => 'Anudīp',
+            'abbr_wp' => 'anudīpanī.',
+        ],
+    ],
+    25 => [
+        2 => [
+            'abbr_my' => 'paṭṭhānuddesa dīpanīpāṭha',
+            'abbr_pts' => 'Paṭṭhud',
+            'abbr_wp' => 'paṭṭhānuddesadīpanī.',
+        ],
+    ],
+    26 => [
+        2 => [
+            'abbr_my' => 'caturārakkhadīpanī',
+            'abbr_pts' => 'Catur',
+            'abbr_wp' => 'caturārakkhadīpanī.',
+        ],
+    ],
+    27 => [
+        2 => [
+            'abbr_my' => 'kavidappaṇanīti',
+            'abbr_pts' => 'Kavid',
+            'abbr_wp' => 'kavidappaṇanīti.',
+        ],
+    ],
+    28 => [
+        2 => [
+            'abbr_my' => 'nītimañjarī',
+            'abbr_pts' => 'Nītim',
+            'abbr_wp' => 'nītimañjarī.',
+        ],
+    ],
+    29 => [
+        2 => [
+            'abbr_my' => 'dhammanīti',
+            'abbr_pts' => 'Dhn',
+            'abbr_wp' => 'dhammanīti.',
+        ],
+    ],
+    30 => [
+        2 => [
+            'abbr_my' => 'mahārahanīti',
+            'abbr_pts' => 'Mhn',
+            'abbr_wp' => 'mahārahanīti.',
+        ],
+    ],
+    31 => [
+        2 => [
+            'abbr_my' => 'lokanīti',
+            'abbr_pts' => 'Ln',
+            'abbr_wp' => 'lokanīti.',
+        ],
+    ],
+    32 => [
+        2 => [
+            'abbr_my' => 'suttantanīti',
+            'abbr_pts' => 'Sutn',
+            'abbr_wp' => 'suttantanīti.',
+        ],
+        134 => [
+            'abbr_my' => 'vasalasutta',
+            'abbr_pts' => 'Sutn',
+            'abbr_wp' => 'vasalasutta.',
+        ],
+    ],
+    33 => [
+        2 => [
+            'abbr_my' => 'sūrassatīnīti 1',
+            'abbr_pts' => 'Sūrn',
+            'abbr_wp' => 'sūrassatīnīti.',
+        ],
+    ],
+    34 => [
+        2 => [
+            'abbr_my' => 'cāṇakyanīti',
+            'abbr_pts' => 'Cāṇ',
+            'abbr_wp' => 'cāṇakyanīti.',
+        ],
+    ],
+    35 => [
+        2 => [
+            'abbr_my' => 'naradakkhadīpanī',
+            'abbr_pts' => 'Narad',
+            'abbr_wp' => 'naradakkhadīpanī.',
+        ],
+    ],
+    36 => [
+        2 => [
+            'abbr_my' => 'rasavāhinī',
+            'abbr_pts' => 'Ras',
+            'abbr_wp' => 'rasavāhinī.',
+        ],
+    ],
+    37 => [
+        2 => [
+            'abbr_my' => 'sīmavisodhanī',
+            'abbr_pts' => 'Sīmav',
+            'abbr_wp' => 'sīmavisodhanī.',
+        ],
+    ],
+    38 => [
+        2 => [
+            'abbr_my' => 'vessantarāgīti',
+            'abbr_pts' => 'Vessag',
+            'abbr_wp' => 'vessantarāgīti.',
+        ],
+    ],
+    39 => [
+        3 => [
+            'abbr_my' => '(saṅgayana-puccha vissajjanā) dīghanikāye',
+            'abbr_pts' => 'SgyDn',
+            'abbr_wp' => 'saṅgāyana.dī.',
+        ],
+    ],
+    40 => [
+        3 => [
+            'abbr_my' => '(saṅgayana-puccha vissajjanā) majjhimanikāya 1',
+            'abbr_pts' => 'SgyMn',
+            'abbr_wp' => 'saṅgāyana.ma.',
+        ],
+    ],
+    41 => [
+        3 => [
+            'abbr_my' => '(saṅgayana-puccha vissajjanā) saṃyuttanikāye 1',
+            'abbr_pts' => 'SgySn',
+            'abbr_wp' => 'saṅgāyana.saṃ.',
+        ],
+    ],
+    42 => [
+        3 => [
+            'abbr_my' => '(saṅgayana-puccha vissajjanā) aṅguttaranikāye 1',
+            'abbr_pts' => 'SgyAn',
+            'abbr_wp' => 'saṅgāyana.a.',
+        ],
+    ],
+    43 => [
+        2 => [
+            'abbr_my' => '(saṅgayana-puccha vissajjanā) vinayapiṭaka 1',
+            'abbr_pts' => 'SgyVin',
+            'abbr_wp' => 'saṅgāyana.vinaya.',
+        ],
+    ],
+    44 => [
+        2 => [
+            'abbr_my' => '(saṅgayana-puccha vissajjanā) abhidhammapiṭaka',
+            'abbr_pts' => 'SgyAbh',
+            'abbr_wp' => 'saṅgāyana.abhidhamma.',
+        ],
+    ],
+    45 => [
+        2 => [
+            'abbr_my' => '(saṅgayana-puccha vissajjanā) aṭṭhakathā 1',
+            'abbr_pts' => 'SgyAṭṭh',
+            'abbr_wp' => 'saṅgāyana.aṭṭhakathā.',
+        ],
+    ],
+    46 => [
+        1 => [
+            'abbr_my' => 'milida-ṭī',
+            'abbr_pts' => 'MilT',
+            'abbr_wp' => 'milinda.ṭī.',
+        ],
+    ],
+    47 => [
+        1 => [
+            'abbr_my' => 'padamañjarī',
+            'abbr_pts' => 'Padam',
+            'abbr_wp' => 'padamañjarī.',
+        ],
+    ],
+    48 => [
+        1 => [
+            'abbr_my' => 'padasādhanaṃ',
+            'abbr_pts' => 'Padas',
+            'abbr_wp' => 'padasādhana.',
+        ],
+    ],
+    49 => [
+        1 => [
+            'abbr_my' => 'saddabindu pakaraṇaṃ',
+            'abbr_pts' => 'Saddb',
+            'abbr_wp' => 'saddabindu.',
+        ],
+    ],
+    50 => [
+        1 => [
+            'abbr_my' => 'kaccāyana dhātu mañjūsā',
+            'abbr_pts' => 'Dhātum',
+            'abbr_wp' => 'kaccāyana.dhātumañjūsā.',
+        ],
+    ],
+    51 => [
+        1 => [
+            'abbr_my' => 'samantakūṭavaṇṇanā',
+            'abbr_pts' => 'Samantak',
+            'abbr_wp' => 'samantakūṭavaṇṇanā.',
+        ],
+    ],
+    52 => [
+        1 => [
+            'abbr_my' => 'moggallāna vuttivivaraṇapañcikā',
+            'abbr_pts' => 'MoggP',
+            'abbr_wp' => 'moggallāna.vuttivivaraṇapañcikā.',
+        ],
+    ],
+    53 => [
+        1 => [
+            'abbr_my' => 'thupavaṃsa',
+            'abbr_pts' => 'Thūp',
+            'abbr_wp' => 'thupavaṃsa.',
+        ],
+    ],
+    54 => [
+        1 => [
+            'abbr_my' => 'dāṭhāvaṃsa',
+            'abbr_pts' => 'Dāṭh',
+            'abbr_wp' => 'dāṭhāvaṃsa.',
+        ],
+    ],
+    55 => [
+        1 => [
+            'abbr_my' => 'dhātupāṭha vilāsiniyā',
+            'abbr_pts' => 'Dhātup',
+            'abbr_wp' => 'dhātupāṭha.vilāsinī.',
+        ],
+    ],
+    56 => [
+        1 => [
+            'abbr_my' => 'dhātuvaṃsa',
+            'abbr_pts' => 'Dhātuv',
+            'abbr_wp' => 'dhātuvaṃsa.',
+        ],
+    ],
+    57 => [
+        1 => [
+            'abbr_my' => 'hatthavanagallavihāra vaṃso',
+            'abbr_pts' => 'Hatth',
+            'abbr_wp' => 'hatthavanagallavihāravaṃsa.',
+        ],
+    ],
+    58 => [
+        1 => [
+            'abbr_my' => 'jinacaritaya',
+            'abbr_pts' => 'Jinac',
+            'abbr_wp' => 'jinacarita.',
+        ],
+    ],
+    59 => [
+        1 => [
+            'abbr_my' => 'jinavaṃsadīpaṃ',
+            'abbr_pts' => 'Jinav',
+            'abbr_wp' => 'jinavaṃsadīpa.',
+        ],
+    ],
+    60 => [
+        1 => [
+            'abbr_my' => 'telakaṭāhagāthā',
+            'abbr_pts' => 'Tel',
+            'abbr_wp' => 'telakaṭāhagāthā.',
+        ],
+    ],
+    61 => [
+        2 => [
+            'abbr_my' => 'cūḷaganthavaṃsa',
+            'abbr_pts' => 'Cgv',
+            'abbr_wp' => 'cūḷaganthavaṃsa.',
+        ],
+    ],
+    62 => [
+        2 => [
+            'abbr_my' => 'sāsanavaṃsappadīpikā',
+            'abbr_pts' => 'Sāsp',
+            'abbr_wp' => 'sāsanavaṃsappadīpikā.',
+        ],
+    ],
+    63 => [
+        2 => [
+            'abbr_my' => 'mahāvaṃsa',
+            'abbr_pts' => 'Mhv',
+            'abbr_wp' => 'mahāvaṃsa.',
+        ],
+    ],
+    64 => [
+        2 => [
+            'abbr_my' => 'visuddhi 1',
+            'abbr_pts' => 'Vism',
+            'abbr_wp' => 'visuddhi.1.',
+        ],
+    ],
+    65 => [
+        2 => [
+            'abbr_my' => 'visuddhi 2',
+            'abbr_pts' => 'Vism',
+            'abbr_wp' => 'visuddhi.2.',
+        ],
+    ],
+    66 => [
+        2 => [
+            'abbr_my' => 'mahāṭī 1',
+            'abbr_pts' => 'VismT',
+            'abbr_wp' => 'visuddhi.mahāṭī.1.',
+        ],
+    ],
+    67 => [
+        2 => [
+            'abbr_my' => 'mahāṭī 2',
+            'abbr_pts' => 'VismT',
+            'abbr_wp' => 'visuddhi.mahāṭī.2.',
+        ],
+    ],
+    68 => [
+        2 => [
+            'abbr_my' => 'visuddhimagga nidānakathā',
+            'abbr_pts' => 'VismNid',
+            'abbr_wp' => 'visuddhi.nidāna.',
+        ],
+    ],
+    69 => [
+        3 => [
+            'abbr_my' => 'paṭṭhāna 2',
+            'abbr_pts' => 'Paṭṭh',
+            'abbr_wp' => 'paṭṭhāna.2.',
+        ],
+    ],
+    70 => [
+        3 => [
+            'abbr_my' => 'paṭṭhāna 3',
+            'abbr_pts' => 'Paṭṭh',
+            'abbr_wp' => 'paṭṭhāna.3.',
+        ],
+    ],
+    71 => [
+        3 => [
+            'abbr_my' => 'paṭṭhāna 4',
+            'abbr_pts' => 'Paṭṭh',
+            'abbr_wp' => 'paṭṭhāna.4.',
+        ],
+    ],
+    72 => [
+        3 => [
+            'abbr_my' => 'paṭṭhāna 5',
+            'abbr_pts' => 'Paṭṭh',
+            'abbr_wp' => 'paṭṭhāna.5.',
+        ],
+    ],
+    73 => [
+        3 => [
+            'abbr_my' => 'abhi 1',
+            'abbr_pts' => 'Dhs',
+            'abbr_wp' => 'dha.sa.',
+        ],
+    ],
+    74 => [
+        3 => [
+            'abbr_my' => 'abhi 2',
+            'abbr_pts' => 'Vibh',
+            'abbr_wp' => 'vibha.',
+        ],
+    ],
+    75 => [
+        3 => [
+            'abbr_my' => 'abhi 3',
+            'abbr_pts' => 'Dhātuk',
+            'abbr_wp' => 'dhātu.kathā.',
+        ],
+    ],
+    76 => [
+        3 => [
+            'abbr_my' => 'abhi 4',
+            'abbr_pts' => 'Pugg',
+            'abbr_wp' => 'puggala.paññatti.',
+        ],
+    ],
+    77 => [
+        3 => [
+            'abbr_my' => 'abhi 5',
+            'abbr_pts' => 'Kv',
+            'abbr_wp' => 'kathā.vatthu.',
+        ],
+    ],
+    78 => [
+        3 => [
+            'abbr_my' => 'abhi 6',
+            'abbr_pts' => 'Yam',
+            'abbr_wp' => 'yama.1.',
+        ],
+    ],
+    79 => [
+        3 => [
+            'abbr_my' => 'abhi 7',
+            'abbr_pts' => 'Yam',
+            'abbr_wp' => 'yama.2.',
+        ],
+    ],
+    80 => [
+        3 => [
+            'abbr_my' => 'abhi 8',
+            'abbr_pts' => 'Yam',
+            'abbr_wp' => 'yama.3.',
+        ],
+    ],
+    81 => [
+        3 => [
+            'abbr_my' => 'paṭṭhāna 1',
+            'abbr_pts' => 'Paṭṭh',
+            'abbr_wp' => 'paṭṭhāna.1.',
+        ],
+    ],
+    82 => [
+        3 => [
+            'abbr_my' => 'aṃ 3',
+            'abbr_pts' => 'An V',
+            'abbr_wp' => 'a.ni.10.',
+        ],
+    ],
+    83 => [
+        3 => [
+            'abbr_my' => 'aṃ 3',
+            'abbr_pts' => 'An V',
+            'abbr_wp' => 'a.ni.11.',
+        ],
+    ],
+    84 => [
+        3 => [
+            'abbr_my' => 'aṃ 1',
+            'abbr_pts' => 'An I',
+            'abbr_wp' => 'a.ni.1.',
+        ],
+    ],
+    85 => [
+        3 => [
+            'abbr_my' => 'aṃ 1',
+            'abbr_pts' => 'An I',
+            'abbr_wp' => 'a.ni.2.',
+        ],
+    ],
+    86 => [
+        3 => [
+            'abbr_my' => 'aṃ 1',
+            'abbr_pts' => 'An I',
+            'abbr_wp' => 'a.ni.3.',
+        ],
+    ],
+    87 => [
+        3 => [
+            'abbr_my' => 'aṃ 1',
+            'abbr_pts' => 'An II',
+            'abbr_wp' => 'a.ni.4.',
+        ],
+    ],
+    88 => [
+        3 => [
+            'abbr_my' => 'aṃ 2',
+            'abbr_pts' => 'An III',
+            'abbr_wp' => 'a.ni.5.',
+        ],
+    ],
+    89 => [
+        3 => [
+            'abbr_my' => 'aṃ 2',
+            'abbr_pts' => 'An III',
+            'abbr_wp' => 'a.ni.6.',
+        ],
+    ],
+    90 => [
+        3 => [
+            'abbr_my' => 'aṃ 2',
+            'abbr_pts' => 'An IV',
+            'abbr_wp' => 'a.ni.7.',
+        ],
+    ],
+    91 => [
+        3 => [
+            'abbr_my' => 'aṃ 3',
+            'abbr_pts' => 'An IV',
+            'abbr_wp' => 'a.ni.8.',
+        ],
+    ],
+    92 => [
+        3 => [
+            'abbr_my' => 'aṃ 3',
+            'abbr_pts' => 'An IV',
+            'abbr_wp' => 'a.ni.9.',
+        ],
+    ],
+    93 => [
+        3 => [
+            'abbr_my' => 'dī 1',
+            'abbr_pts' => 'Dn I',
+            'abbr_wp' => 'dī.ni.1.',
+        ],
+    ],
+    94 => [
+        3 => [
+            'abbr_my' => 'dī 2',
+            'abbr_pts' => 'Dn II',
+            'abbr_wp' => 'dī.ni.2.',
+        ],
+    ],
+    95 => [
+        3 => [
+            'abbr_my' => 'dī 3',
+            'abbr_pts' => 'Dn III',
+            'abbr_wp' => 'dī.ni.3.',
+        ],
+    ],
+    96 => [
+        4 => [
+            'abbr_my' => 'abhi-ṭṭha',
+            'abbr_pts' => 'DhsA',
+            'abbr_wp' => 'dha.sa.aṭṭha.',
+        ],
+    ],
+    97 => [
+        4 => [
+            'abbr_my' => 'abhi-ṭṭha',
+            'abbr_pts' => 'VibhA',
+            'abbr_wp' => 'vibha.aṭṭha.',
+        ],
+    ],
+    98 => [
+        4 => [
+            'abbr_my' => 'abhi-ṭṭha',
+            'abbr_pts' => 'DhātukA',
+            'abbr_wp' => 'dhātu.kathā.aṭṭha.',
+        ],
+        173 => [
+            'abbr_my' => 'abhi-ṭṭha',
+            'abbr_pts' => 'PuggA',
+            'abbr_wp' => 'puggala.paññatti.aṭṭha.',
+        ],
+        474 => [
+            'abbr_my' => 'abhi-ṭṭha',
+            'abbr_pts' => 'KvA',
+            'abbr_wp' => 'kathā.vatthu.aṭṭha.',
+        ],
+        1623 => [
+            'abbr_my' => 'abhi-ṭṭha',
+            'abbr_pts' => 'YamA',
+            'abbr_wp' => 'yama.aṭṭha.',
+        ],
+        1880 => [
+            'abbr_my' => 'abhi-ṭṭha',
+            'abbr_pts' => 'PaṭṭhA',
+            'abbr_wp' => 'paṭṭhāna.aṭṭha.',
+        ],
+    ],
+    99 => [
+        3 => [
+            'abbr_my' => 'aṃ-ṭṭha 1',
+            'abbr_pts' => 'AnA I',
+            'abbr_wp' => 'a.ni.aṭṭha.1.',
+        ],
+    ],
+    100 => [
+        3 => [
+            'abbr_my' => 'aṃ-ṭṭha 2',
+            'abbr_pts' => 'AnA II',
+            'abbr_wp' => 'a.ni.aṭṭha.2.',
+        ],
+        325 => [
+            'abbr_my' => 'aṃ-ṭṭha 2',
+            'abbr_pts' => 'AnA II',
+            'abbr_wp' => 'a.ni.aṭṭha.3.',
+        ],
+        997 => [
+            'abbr_my' => 'aṃ-ṭṭha 2',
+            'abbr_pts' => 'AnA III',
+            'abbr_wp' => 'a.ni.aṭṭha.4.',
+        ],
+    ],
+    101 => [
+        3 => [
+            'abbr_my' => 'aṃ-ṭṭha 3',
+            'abbr_pts' => 'AnA III',
+            'abbr_wp' => 'a.ni.aṭṭha.5.',
+        ],
+        503 => [
+            'abbr_my' => 'aṃ-ṭṭha 3',
+            'abbr_pts' => 'AnA III',
+            'abbr_wp' => 'a.ni.aṭṭha.6.',
+        ],
+        792 => [
+            'abbr_my' => 'aṃ-ṭṭha 3',
+            'abbr_pts' => 'AnA IV',
+            'abbr_wp' => 'a.ni.aṭṭha.7.',
+        ],
+    ],
+    102 => [
+        3 => [
+            'abbr_my' => 'aṃ-ṭṭha 3',
+            'abbr_pts' => 'AnA IV',
+            'abbr_wp' => 'a.ni.aṭṭha.8.',
+        ],
+        275 => [
+            'abbr_my' => 'aṃ-ṭṭha 3',
+            'abbr_pts' => 'AnA IV',
+            'abbr_wp' => 'a.ni.aṭṭha.9.',
+        ],
+        409 => [
+            'abbr_my' => 'aṃ-ṭṭha 3',
+            'abbr_pts' => 'AnA V',
+            'abbr_wp' => 'a.ni.aṭṭha.10.',
+        ],
+        706 => [
+            'abbr_my' => 'aṃ-ṭṭha 3',
+            'abbr_pts' => 'AnA V',
+            'abbr_wp' => 'a.ni.aṭṭha.11.',
+        ],
+    ],
+    103 => [
+        3 => [
+            'abbr_my' => 'dī-ṭṭha 1',
+            'abbr_pts' => 'DnA I',
+            'abbr_wp' => 'dī.ni.aṭṭha.1.',
+        ],
+    ],
+    104 => [
+        3 => [
+            'abbr_my' => 'dī-ṭṭha 2',
+            'abbr_pts' => 'DnA II',
+            'abbr_wp' => 'dī.ni.aṭṭha.2.',
+        ],
+    ],
+    105 => [
+        3 => [
+            'abbr_my' => 'dī-ṭṭha 3',
+            'abbr_pts' => 'DnA III',
+            'abbr_wp' => 'dī.ni.aṭṭha.3.',
+        ],
+    ],
+    106 => [
+        3 => [
+            'abbr_my' => 'therīgāthā-ṭṭha',
+            'abbr_pts' => 'ThīA',
+            'abbr_wp' => 'therī.aṭṭha.',
+        ],
+    ],
+    107 => [
+        3 => [
+            'abbr_my' => 'apadāna-ṭṭha 1',
+            'abbr_pts' => 'ApA',
+            'abbr_wp' => 'apa.aṭṭha.',
+        ],
+    ],
+    108 => [
+        3 => [
+            'abbr_my' => 'buddhavaṃsa-ṭṭha',
+            'abbr_pts' => 'BvA',
+            'abbr_wp' => 'buddhavaṃ.aṭṭha.',
+        ],
+    ],
+    109 => [
+        3 => [
+            'abbr_my' => 'cariyāpiṭaka-ṭṭha',
+            'abbr_pts' => 'CpA',
+            'abbr_wp' => 'cariyā.aṭṭha.',
+        ],
+    ],
+    110 => [
+        4 => [
+            'abbr_my' => 'jātaka-ṭṭha 1',
+            'abbr_pts' => 'JaA I',
+            'abbr_wp' => 'jā.aṭṭha.1.',
+        ],
+    ],
+    111 => [
+        3 => [
+            'abbr_my' => 'jātaka-ṭṭha 2',
+            'abbr_pts' => 'JaA II',
+            'abbr_wp' => 'jā.aṭṭha.2.',
+        ],
+    ],
+    112 => [
+        4 => [
+            'abbr_my' => 'jātaka-ṭṭha 3',
+            'abbr_pts' => 'JaA III',
+            'abbr_wp' => 'jā.aṭṭha.3.',
+        ],
+    ],
+    113 => [
+        4 => [
+            'abbr_my' => 'jātaka-ṭṭha 4',
+            'abbr_pts' => 'JaA IV',
+            'abbr_wp' => 'jā.aṭṭha.4.',
+        ],
+        5312 => [
+            'abbr_my' => 'jātaka-ṭṭha 5',
+            'abbr_pts' => 'JaA V',
+            'abbr_wp' => 'jā.aṭṭha.5.',
+        ],
+    ],
+    114 => [
+        1 => [
+            'abbr_my' => 'jātaka-ṭṭha 5',
+            'abbr_pts' => 'JaA V',
+            'abbr_wp' => 'jā.aṭṭha.6.',
+        ],
+    ],
+    115 => [
+        3 => [
+            'abbr_my' => 'jātaka-ṭṭha 6',
+            'abbr_pts' => 'JaA VI',
+            'abbr_wp' => 'jā.aṭṭha.7.',
+        ],
+    ],
+    116 => [
+        3 => [
+            'abbr_my' => 'khuddakapāṭha-ṭṭha',
+            'abbr_pts' => 'KhpA',
+            'abbr_wp' => 'khuddaka.aṭṭha.',
+        ],
+    ],
+    117 => [
+        3 => [
+            'abbr_my' => 'jātaka-ṭṭha 7',
+            'abbr_pts' => 'JaA VI',
+            'abbr_wp' => 'jā.aṭṭha.8.',
+        ],
+    ],
+    118 => [
+        3 => [
+            'abbr_my' => 'mahāni-ṭṭha',
+            'abbr_pts' => 'MndA I',
+            'abbr_wp' => 'mahāni.aṭṭha.',
+        ],
+    ],
+    119 => [
+        3 => [
+            'abbr_my' => 'cūḷani-ṭṭha',
+            'abbr_pts' => 'CndA',
+            'abbr_wp' => 'cūḷani.aṭṭha.',
+        ],
+    ],
+    120 => [
+        3 => [
+            'abbr_my' => 'paṭisaṃ-ṭṭha 1',
+            'abbr_pts' => 'PaṭisA I',
+            'abbr_wp' => 'paṭi.ma.aṭṭha.',
+        ],
+    ],
+    121 => [
+        3 => [
+            'abbr_my' => 'netti-ṭṭha',
+            'abbr_pts' => 'NettA',
+            'abbr_wp' => 'netti.aṭṭha.',
+        ],
+    ],
+    122 => [
+        3 => [
+            'abbr_my' => 'dhammapada-ṭṭha 1',
+            'abbr_pts' => 'DhpA I',
+            'abbr_wp' => 'dhamma.aṭṭha.',
+        ],
+    ],
+    123 => [
+        3 => [
+            'abbr_my' => 'udāna-ṭṭha',
+            'abbr_pts' => 'UdA',
+            'abbr_wp' => 'udāna.aṭṭha.',
+        ],
+    ],
+    124 => [
+        3 => [
+            'abbr_my' => 'itivuttaka-ṭṭha',
+            'abbr_pts' => 'ItA I',
+            'abbr_wp' => 'itivutta.aṭṭha.',
+        ],
+    ],
+    125 => [
+        3 => [
+            'abbr_my' => 'suttanipāta-ṭṭha 1',
+            'abbr_pts' => 'SnpA I',
+            'abbr_wp' => 'suttani.aṭṭha.',
+        ],
+    ],
+    126 => [
+        3 => [
+            'abbr_my' => 'vimānavatthu-ṭṭha',
+            'abbr_pts' => 'VvA',
+            'abbr_wp' => 'vimāna.aṭṭha.',
+        ],
+    ],
+    127 => [
+        3 => [
+            'abbr_my' => 'petavatthu-ṭṭha',
+            'abbr_pts' => 'PvA',
+            'abbr_wp' => 'peta.aṭṭha.',
+        ],
+    ],
+    128 => [
+        3 => [
+            'abbr_my' => 'theragāthā-ṭṭha 1',
+            'abbr_pts' => 'ThA I',
+            'abbr_wp' => 'thera.aṭṭha.1.',
+        ],
+    ],
+    129 => [
+        3 => [
+            'abbr_my' => 'theragāthā-ṭṭha 2',
+            'abbr_pts' => 'ThA II',
+            'abbr_wp' => 'thera.aṭṭha.2.',
+        ],
+    ],
+    130 => [
+        3 => [
+            'abbr_my' => 'ma-ṭṭha 1',
+            'abbr_pts' => 'MnA I',
+            'abbr_wp' => 'ma.ni.aṭṭha.1.',
+        ],
+    ],
+    131 => [
+        3 => [
+            'abbr_my' => 'ma-ṭṭha 3',
+            'abbr_pts' => 'MnA III',
+            'abbr_wp' => 'ma.ni.aṭṭha.2.',
+        ],
+    ],
+    132 => [
+        3 => [
+            'abbr_my' => 'ma-ṭṭha 4',
+            'abbr_pts' => 'MnA IV',
+            'abbr_wp' => 'ma.ni.aṭṭha.3.',
+        ],
+    ],
+    133 => [
+        3 => [
+            'abbr_my' => 'saṃ-ṭṭha 1',
+            'abbr_pts' => 'SnA I',
+            'abbr_wp' => 'saṃ.ni.aṭṭha.1.',
+        ],
+    ],
+    134 => [
+        3 => [
+            'abbr_my' => 'saṃ-ṭṭha 2',
+            'abbr_pts' => 'SnA II',
+            'abbr_wp' => 'saṃ.ni.aṭṭha.2.',
+        ],
+    ],
+    135 => [
+        3 => [
+            'abbr_my' => 'saṃ-ṭṭha 2',
+            'abbr_pts' => 'SnA II',
+            'abbr_wp' => 'saṃ.ni.aṭṭha.3.',
+        ],
+    ],
+    136 => [
+        3 => [
+            'abbr_my' => 'saṃ-ṭṭha 3',
+            'abbr_pts' => 'SnA II',
+            'abbr_wp' => 'saṃ.ni.aṭṭha.4.',
+        ],
+    ],
+    137 => [
+        3 => [
+            'abbr_my' => 'saṃ-ṭṭha 3',
+            'abbr_pts' => 'SnA III',
+            'abbr_wp' => 'saṃ.ni.aṭṭha.5.',
+        ],
+    ],
+    138 => [
+        3 => [
+            'abbr_my' => 'vi-ṭṭha 1',
+            'abbr_pts' => 'VinA I',
+            'abbr_wp' => 'pārā.aṭṭha.',
+        ],
+    ],
+    139 => [
+        3 => [
+            'abbr_my' => 'vi-ṭṭha',
+            'abbr_pts' => 'VinA IV',
+            'abbr_wp' => 'pāci.aṭṭha.',
+        ],
+        925 => [
+            'abbr_my' => 'vi-ṭṭha',
+            'abbr_pts' => 'VinA IV',
+            'abbr_wp' => 'pāci.aṭṭha.bhikkhunī.',
+        ],
+    ],
+    140 => [
+        3 => [
+            'abbr_my' => 'vi-ṭṭha',
+            'abbr_pts' => 'VinA V',
+            'abbr_wp' => 'mahāva.aṭṭha.',
+        ],
+    ],
+    141 => [
+        3 => [
+            'abbr_my' => 'vi-ṭṭha',
+            'abbr_pts' => 'VinA VI',
+            'abbr_wp' => 'cūḷava.aṭṭha.',
+        ],
+    ],
+    142 => [
+        3 => [
+            'abbr_my' => 'vi-ṭṭha',
+            'abbr_pts' => 'VinA VII',
+            'abbr_wp' => 'pari.aṭṭha.',
+        ],
+    ],
+    143 => [
+        3 => [
+            'abbr_my' => 'apadān 1',
+            'abbr_pts' => 'Ap I',
+            'abbr_wp' => 'apa.1.',
+        ],
+    ],
+    144 => [
+        3 => [
+            'abbr_my' => 'apadān 2',
+            'abbr_pts' => 'Ap II',
+            'abbr_wp' => 'apa.2.',
+        ],
+        6534 => [
+            'abbr_my' => 'apadān 2',
+            'abbr_pts' => 'Ap II',
+            'abbr_wp' => 'therī.apa.',
+        ],
+    ],
+    145 => [
+        3 => [
+            'abbr_my' => 'buddhavaṃsa 2',
+            'abbr_pts' => 'Bv',
+            'abbr_wp' => 'buddhavaṃ.',
+        ],
+    ],
+    146 => [
+        3 => [
+            'abbr_my' => 'cariyāpiṭaka 2',
+            'abbr_pts' => 'Cp',
+            'abbr_wp' => 'cariyā.',
+        ],
+    ],
+    147 => [
+        3 => [
+            'abbr_my' => 'jātaka 2',
+            'abbr_pts' => 'Ja',
+            'abbr_wp' => 'jā.2.',
+        ],
+    ],
+    148 => [
+        3 => [
+            'abbr_my' => 'jātaka 1',
+            'abbr_pts' => 'Ja',
+            'abbr_wp' => 'jā.1.',
+        ],
+    ],
+    149 => [
+        3 => [
+            'abbr_my' => 'mahāni',
+            'abbr_pts' => 'Mnd I',
+            'abbr_wp' => 'mahāni.',
+        ],
+    ],
+    150 => [
+        3 => [
+            'abbr_my' => 'cūḷani',
+            'abbr_pts' => 'Cnd',
+            'abbr_wp' => 'cūḷani.',
+        ],
+    ],
+    151 => [
+        3 => [
+            'abbr_my' => 'paṭisaṃ',
+            'abbr_pts' => 'Paṭis I',
+            'abbr_wp' => 'paṭi.ma.',
+        ],
+    ],
+    152 => [
+        3 => [
+            'abbr_my' => 'milinda',
+            'abbr_pts' => 'Mil',
+            'abbr_wp' => 'milinda.',
+        ],
+    ],
+    153 => [
+        3 => [
+            'abbr_my' => 'nettippakaraṇa',
+            'abbr_pts' => 'Nett',
+            'abbr_wp' => 'netti.',
+        ],
+    ],
+    154 => [
+        3 => [
+            'abbr_my' => 'khuddakapāṭha',
+            'abbr_pts' => 'Khp',
+            'abbr_wp' => 'khuddaka.',
+        ],
+    ],
+    155 => [
+        3 => [
+            'abbr_my' => 'peṭakopadesa',
+            'abbr_pts' => 'Peṭ',
+            'abbr_wp' => 'peṭako.',
+        ],
+    ],
+    156 => [
+        3 => [
+            'abbr_my' => 'dhammapada',
+            'abbr_pts' => 'Dhp',
+            'abbr_wp' => 'dhamma.',
+        ],
+    ],
+    157 => [
+        3 => [
+            'abbr_my' => 'udāna',
+            'abbr_pts' => 'Ud',
+            'abbr_wp' => 'udāna.',
+        ],
+    ],
+    158 => [
+        3 => [
+            'abbr_my' => 'itivuttaka',
+            'abbr_pts' => 'It',
+            'abbr_wp' => 'itivutta.',
+        ],
+    ],
+    159 => [
+        3 => [
+            'abbr_my' => 'suttanipāta',
+            'abbr_pts' => 'Snp',
+            'abbr_wp' => 'suttani.',
+        ],
+    ],
+    160 => [
+        3 => [
+            'abbr_my' => 'vimānavatthu',
+            'abbr_pts' => 'Vv',
+            'abbr_wp' => 'vimāna.',
+        ],
+    ],
+    161 => [
+        3 => [
+            'abbr_my' => 'petavatthu',
+            'abbr_pts' => 'Pv',
+            'abbr_wp' => 'peta.',
+        ],
+    ],
+    162 => [
+        3 => [
+            'abbr_my' => 'theragāthā',
+            'abbr_pts' => 'Th',
+            'abbr_wp' => 'theragāthā.',
+        ],
+    ],
+    163 => [
+        3 => [
+            'abbr_my' => 'therīgāthā',
+            'abbr_pts' => 'Thī',
+            'abbr_wp' => 'therī.',
+        ],
+    ],
+    164 => [
+        3 => [
+            'abbr_my' => 'ma 1',
+            'abbr_pts' => 'Mn I',
+            'abbr_wp' => 'ma.ni.1.',
+        ],
+    ],
+    165 => [
+        3 => [
+            'abbr_my' => 'ma 2',
+            'abbr_pts' => 'Mn I',
+            'abbr_wp' => 'ma.ni.2.',
+        ],
+    ],
+    166 => [
+        3 => [
+            'abbr_my' => 'ma 3',
+            'abbr_pts' => 'Mn II',
+            'abbr_wp' => 'ma.ni.3.',
+        ],
+    ],
+    167 => [
+        3 => [
+            'abbr_my' => 'saṃ 1',
+            'abbr_pts' => 'Sn I',
+            'abbr_wp' => 'saṃ.ni.1.',
+        ],
+    ],
+    168 => [
+        3 => [
+            'abbr_my' => 'saṃ 1',
+            'abbr_pts' => 'Sn II',
+            'abbr_wp' => 'saṃ.ni.2.',
+        ],
+    ],
+    169 => [
+        3 => [
+            'abbr_my' => 'saṃ 2',
+            'abbr_pts' => 'Sn III',
+            'abbr_wp' => 'saṃ.ni.3.',
+        ],
+    ],
+    170 => [
+        3 => [
+            'abbr_my' => 'saṃ 2',
+            'abbr_pts' => 'Sn IV',
+            'abbr_wp' => 'saṃ.ni.4.',
+        ],
+    ],
+    171 => [
+        3 => [
+            'abbr_my' => 'saṃ 3',
+            'abbr_pts' => 'Sn V',
+            'abbr_wp' => 'saṃ.ni.5.',
+        ],
+    ],
+    172 => [
+        3 => [
+            'abbr_my' => 'mūlaṭī',
+            'abbr_pts' => 'DhsT',
+            'abbr_wp' => 'dha.sa.mūlaṭī.',
+        ],
+    ],
+    173 => [
+        3 => [
+            'abbr_my' => 'mūlaṭī',
+            'abbr_pts' => 'VibhT',
+            'abbr_wp' => 'vibha.mūlaṭī.',
+        ],
+        1155 => [
+            'abbr_my' => 'anuṭī',
+            'abbr_pts' => 'VibhAnuT',
+            'abbr_wp' => 'vibha.anuṭī.',
+        ],
+    ],
+    174 => [
+        4 => [
+            'abbr_my' => 'mūlaṭī',
+            'abbr_pts' => 'DhātukT',
+            'abbr_wp' => 'dhātu.kathā.mūlaṭī.',
+        ],
+        100 => [
+            'abbr_my' => 'mūlaṭī',
+            'abbr_pts' => 'PuggT',
+            'abbr_wp' => 'puggala.paññatti.mūlaṭī.',
+        ],
+        201 => [
+            'abbr_my' => 'mūlaṭī',
+            'abbr_pts' => 'KvT',
+            'abbr_wp' => 'kathā.vatthu.mūlaṭī.',
+        ],
+        865 => [
+            'abbr_my' => 'mūlaṭī',
+            'abbr_pts' => 'YamT',
+            'abbr_wp' => 'yama.mūlaṭī.',
+        ],
+        1059 => [
+            'abbr_my' => 'mūlaṭī',
+            'abbr_pts' => 'PaṭṭhT',
+            'abbr_wp' => 'paṭṭhāna.mūlaṭī.',
+        ],
+    ],
+    175 => [
+        3 => [
+            'abbr_my' => 'anuṭī',
+            'abbr_pts' => 'DhsAnuT',
+            'abbr_wp' => 'dha.sa.anuṭī.',
+        ],
+    ],
+    176 => [
+        4 => [
+            'abbr_my' => 'anuṭī',
+            'abbr_pts' => 'DhātukAnuT',
+            'abbr_wp' => 'dhātu.kathā.anuṭī.',
+        ],
+        117 => [
+            'abbr_my' => 'anuṭī',
+            'abbr_pts' => 'PuggAnuT',
+            'abbr_wp' => 'puggala.paññatti.anuṭī.',
+        ],
+        213 => [
+            'abbr_my' => 'anuṭī',
+            'abbr_pts' => 'KvAnuT',
+            'abbr_wp' => 'kathā.vatthu.anuṭī.',
+        ],
+        917 => [
+            'abbr_my' => 'anuṭī',
+            'abbr_pts' => 'YamAnuT',
+            'abbr_wp' => 'yama.anuṭī.',
+        ],
+        1139 => [
+            'abbr_my' => 'anuṭī',
+            'abbr_pts' => 'PaṭṭhAnuT',
+            'abbr_wp' => 'paṭṭhāna.anuṭī.',
+        ],
+    ],
+    177 => [
+        2 => [
+            'abbr_my' => 'abhidhammāvatāro',
+            'abbr_pts' => 'Abhāv',
+            'abbr_wp' => 'abhidhammāvatāra.',
+        ],
+        4805 => [
+            'abbr_my' => 'nāmarūpaparicchedo',
+            'abbr_pts' => 'Nāmarp',
+            'abbr_wp' => 'nāmarūpapariccheda.',
+        ],
+        10627 => [
+            'abbr_my' => 'paramatthavinicchayo',
+            'abbr_pts' => 'Paramv',
+            'abbr_wp' => 'paramatthavinicchaya.',
+        ],
+        14272 => [
+            'abbr_my' => 'saccasaṅkhepo',
+            'abbr_pts' => 'Sacc',
+            'abbr_wp' => 'saccasaṅkhepa.',
+        ],
+    ],
+    178 => [
+        2 => [
+            'abbr_my' => 'abhidhammatthasaṅgaho',
+            'abbr_pts' => 'Abhs',
+            'abbr_wp' => 'abhidhammatthasaṅgaha.',
+        ],
+        875 => [
+            'abbr_my' => 'abhidhammatthavibhāvinī-ṭī',
+            'abbr_pts' => 'AbhsT',
+            'abbr_wp' => 'abhidhammatthavibhāvinī.ṭī.',
+        ],
+    ],
+    179 => [
+        2 => [
+            'abbr_my' => 'abhidhammāvatāra-purāṇa-ṭī',
+            'abbr_pts' => 'AbhāvPT',
+            'abbr_wp' => 'abhidhammāvatāra.purāṇaṭī.',
+        ],
+        872 => [
+            'abbr_my' => 'abhidhammāvatāra-abhinava-ṭī 1',
+            'abbr_pts' => 'AbhāvAbhT',
+            'abbr_wp' => 'abhidhammāvatāra.abhinavaṭī.',
+        ],
+    ],
+    180 => [
+        2 => [
+            'abbr_my' => 'abhidhammamātikā',
+            'abbr_pts' => 'AbhMāt',
+            'abbr_wp' => 'abhidhammamātikā.',
+        ],
+        1221 => [
+            'abbr_my' => 'mohavicchedanī',
+            'abbr_pts' => 'Moh',
+            'abbr_wp' => 'mohavicchedanī.',
+        ],
+    ],
+    181 => [
+        3 => [
+            'abbr_my' => 'aṃ-ṭī 1',
+            'abbr_pts' => 'AnT',
+            'abbr_wp' => 'a.ni.ṭī.1.',
+        ],
+    ],
+    182 => [
+        3 => [
+            'abbr_my' => 'aṃ-ṭī 2',
+            'abbr_pts' => 'AnT',
+            'abbr_wp' => 'a.ni.ṭī.2.',
+        ],
+        324 => [
+            'abbr_my' => 'aṃ-ṭī 2',
+            'abbr_pts' => 'AnT',
+            'abbr_wp' => 'a.ni.ṭī.3.',
+        ],
+        1023 => [
+            'abbr_my' => 'aṃ-ṭī 2',
+            'abbr_pts' => 'AnT',
+            'abbr_wp' => 'a.ni.ṭī.4.',
+        ],
+    ],
+    183 => [
+        3 => [
+            'abbr_my' => 'aṃ-ṭī 3',
+            'abbr_pts' => 'AnT',
+            'abbr_wp' => 'a.ni.ṭī.5.',
+        ],
+        470 => [
+            'abbr_my' => 'aṃ-ṭī 3',
+            'abbr_pts' => 'AnT',
+            'abbr_wp' => 'a.ni.ṭī.6.',
+        ],
+        793 => [
+            'abbr_my' => 'aṃ-ṭī 3',
+            'abbr_pts' => 'AnT',
+            'abbr_wp' => 'a.ni.ṭī.7.',
+        ],
+    ],
+    184 => [
+        3 => [
+            'abbr_my' => 'aṃ-ṭī 3',
+            'abbr_pts' => 'AnT',
+            'abbr_wp' => 'a.ni.ṭī.8.',
+        ],
+        278 => [
+            'abbr_my' => 'aṃ-ṭī 3',
+            'abbr_pts' => 'AnT',
+            'abbr_wp' => 'a.ni.ṭī.9.',
+        ],
+        466 => [
+            'abbr_my' => 'aṃ-ṭī 3',
+            'abbr_pts' => 'AnT',
+            'abbr_wp' => 'a.ni.ṭī.10.',
+        ],
+        776 => [
+            'abbr_my' => 'aṃ-ṭī 3',
+            'abbr_pts' => 'AnT',
+            'abbr_wp' => 'a.ni.ṭī.11.',
+        ],
+    ],
+    185 => [
+        3 => [
+            'abbr_my' => 'dī-ṭī 1',
+            'abbr_pts' => 'DnT I',
+            'abbr_wp' => 'dī.ni.ṭī.1.',
+        ],
+    ],
+    186 => [
+        3 => [
+            'abbr_my' => 'dī-ṭī 2',
+            'abbr_pts' => 'DnT II',
+            'abbr_wp' => 'dī.ni.ṭī.2.',
+        ],
+    ],
+    187 => [
+        3 => [
+            'abbr_my' => 'dī-ṭī 3',
+            'abbr_pts' => 'DnT III',
+            'abbr_wp' => 'dī.ni.ṭī.3.',
+        ],
+    ],
+    188 => [
+        3 => [
+            'abbr_my' => 'dī-abhi-ṭī 1',
+            'abbr_pts' => 'DnAbhT',
+            'abbr_wp' => 'dī.ni.abhi.ṭī.1.',
+        ],
+    ],
+    189 => [
+        3 => [
+            'abbr_my' => 'dī-abhi-ṭī 2',
+            'abbr_pts' => 'DnAbhT',
+            'abbr_wp' => 'dī.ni.abhi.ṭī.2.',
+        ],
+    ],
+    190 => [
+        3 => [
+            'abbr_my' => 'nett-ṭī',
+            'abbr_pts' => 'NettT',
+            'abbr_wp' => 'netti.ṭī.',
+        ],
+    ],
+    191 => [
+        3 => [
+            'abbr_my' => 'nett-vi',
+            'abbr_pts' => 'NettVibh',
+            'abbr_wp' => 'netti.vibhā.',
+        ],
+    ],
+    192 => [
+        3 => [
+            'abbr_my' => 'ma-ṭī 1',
+            'abbr_pts' => 'MnT',
+            'abbr_wp' => 'ma.ni.ṭī.1.',
+        ],
+    ],
+    193 => [
+        3 => [
+            'abbr_my' => 'ma-ṭī 2',
+            'abbr_pts' => 'MnT',
+            'abbr_wp' => 'ma.ni.ṭī.2.',
+        ],
+    ],
+    194 => [
+        3 => [
+            'abbr_my' => 'ma-ṭī 2',
+            'abbr_pts' => 'MnT',
+            'abbr_wp' => 'ma.ni.ṭī.3.',
+        ],
+    ],
+    195 => [
+        3 => [
+            'abbr_my' => 'saṃ-ṭī 1',
+            'abbr_pts' => 'SnT',
+            'abbr_wp' => 'saṃ.ni.ṭī.1.',
+        ],
+    ],
+    196 => [
+        3 => [
+            'abbr_my' => 'saṃ-ṭī 2',
+            'abbr_pts' => 'SnT',
+            'abbr_wp' => 'saṃ.ni.ṭī.2.',
+        ],
+    ],
+    197 => [
+        3 => [
+            'abbr_my' => 'saṃ-ṭī 2',
+            'abbr_pts' => 'SnT',
+            'abbr_wp' => 'saṃ.ni.ṭī.3.',
+        ],
+    ],
+    198 => [
+        3 => [
+            'abbr_my' => 'saṃ-ṭī 2',
+            'abbr_pts' => 'SnT',
+            'abbr_wp' => 'saṃ.ni.ṭī.4.',
+        ],
+    ],
+    199 => [
+        3 => [
+            'abbr_my' => 'saṃ-ṭī 2',
+            'abbr_pts' => 'SnT',
+            'abbr_wp' => 'saṃ.ni.ṭī.5.',
+        ],
+    ],
+    200 => [
+        2 => [
+            'abbr_my' => 'vi.vi.',
+            'abbr_pts' => 'VinVn',
+            'abbr_wp' => 'vinayavinicchaya.',
+        ],
+        10176 => [
+            'abbr_my' => 'ut.vi.',
+            'abbr_pts' => 'UttVn',
+            'abbr_wp' => 'uttaravinicchaya.',
+        ],
+    ],
+    201 => [
+        2 => [
+            'abbr_my' => 'vi.vi.-ṭī',
+            'abbr_pts' => 'VinVnT',
+            'abbr_wp' => 'vinayavinicchaya.ṭī.',
+        ],
+        4270 => [
+            'abbr_my' => 'ut.vi.-ṭī',
+            'abbr_pts' => 'UttVnT',
+            'abbr_wp' => 'uttaravinicchaya.ṭī.',
+        ],
+    ],
+    202 => [
+        3 => [
+            'abbr_my' => 'pāci.yo.',
+            'abbr_pts' => 'PācYoj',
+            'abbr_wp' => 'pācityādiyojanā.',
+        ],
+    ],
+    203 => [
+        3 => [
+            'abbr_my' => 'khudda',
+            'abbr_pts' => 'Khuddas',
+            'abbr_wp' => 'khuddasikkhā.',
+        ],
+        1620 => [
+            'abbr_my' => 'khudda pu ṭī',
+            'abbr_pts' => 'KhuddasPT',
+            'abbr_wp' => 'khuddasikkhā.purāṇaṭī.',
+        ],
+        2361 => [
+            'abbr_my' => 'khudda abh ṭī',
+            'abbr_pts' => 'KhuddasAbhT',
+            'abbr_wp' => 'khuddasikkhā.abhinavaṭī.',
+        ],
+        3100 => [
+            'abbr_my' => 'mūlasikkhā',
+            'abbr_pts' => 'Mūlas',
+            'abbr_wp' => 'mūlasikkhā.',
+        ],
+        3481 => [
+            'abbr_my' => 'mūlasikkhā ṭī',
+            'abbr_pts' => 'MūlasT',
+            'abbr_wp' => 'mūlasikkhā.ṭī.',
+        ],
+    ],
+    204 => [
+        3 => [
+            'abbr_my' => 'sārattha 1',
+            'abbr_pts' => 'VinT',
+            'abbr_wp' => 'sārattha.ṭī.1.',
+        ],
+    ],
+    205 => [
+        3 => [
+            'abbr_my' => 'sārattha 2',
+            'abbr_pts' => 'VinT',
+            'abbr_wp' => 'sārattha.ṭī.2.',
+        ],
+    ],
+    206 => [
+        3 => [
+            'abbr_my' => 'sārattha 3',
+            'abbr_pts' => 'VinT',
+            'abbr_wp' => 'sārattha.ṭī.3.',
+        ],
+        717 => [
+            'abbr_my' => 'sārattha 3',
+            'abbr_pts' => 'VinT',
+            'abbr_wp' => 'sārattha.ṭī.3.mahāva.',
+        ],
+        1753 => [
+            'abbr_my' => 'sārattha 3',
+            'abbr_pts' => 'VinT',
+            'abbr_wp' => 'sārattha.ṭī.3.cūḷava.',
+        ],
+        2154 => [
+            'abbr_my' => 'sārattha 3',
+            'abbr_pts' => 'VinT',
+            'abbr_wp' => 'sārattha.ṭī.3.pari.',
+        ],
+    ],
+    207 => [
+        3 => [
+            'abbr_my' => 'pātimokkha',
+            'abbr_pts' => 'Pātim',
+            'abbr_wp' => 'pātimokkha.',
+        ],
+        524 => [
+            'abbr_my' => 'pātimokkha',
+            'abbr_pts' => 'Pātim',
+            'abbr_wp' => 'bhikkhunīpātimokkha.',
+        ],
+        1215 => [
+            'abbr_my' => 'kaṅkhāvitaraṇī',
+            'abbr_pts' => 'Kkh',
+            'abbr_wp' => 'kaṅkhā.aṭṭha.',
+        ],
+    ],
+    208 => [
+        3 => [
+            'abbr_my' => 'vinayasaṅgaha-ṭṭha',
+            'abbr_pts' => 'VinSgh',
+            'abbr_wp' => 'vi.saṅga.aṭṭha.',
+        ],
+    ],
+    209 => [
+        3 => [
+            'abbr_my' => 'vajira-ṭī',
+            'abbr_pts' => 'VjbT',
+            'abbr_wp' => 'vajira.ṭī.',
+        ],
+        166 => [
+            'abbr_my' => 'vajira-ṭī',
+            'abbr_pts' => 'VjbT',
+            'abbr_wp' => 'vajira.ṭī.pārā.',
+        ],
+        992 => [
+            'abbr_my' => 'vajira-ṭī',
+            'abbr_pts' => 'VjbT',
+            'abbr_wp' => 'vajira.ṭī.pāci.',
+        ],
+        1708 => [
+            'abbr_my' => 'vajira-ṭī',
+            'abbr_pts' => 'VjbT',
+            'abbr_wp' => 'vajira.ṭī.mahāva.',
+        ],
+        2217 => [
+            'abbr_my' => 'vajira-ṭī',
+            'abbr_pts' => 'VjbT',
+            'abbr_wp' => 'vajira.ṭī.cūḷava.',
+        ],
+        2428 => [
+            'abbr_my' => 'vajirab-ṭī',
+            'abbr_pts' => 'VjbT',
+            'abbr_wp' => 'vajira.ṭī.pari.',
+        ],
+    ],
+    210 => [
+        3 => [
+            'abbr_my' => 'vimati 1',
+            'abbr_pts' => 'VmvT',
+            'abbr_wp' => 'vimati.ṭī.',
+        ],
+        1658 => [
+            'abbr_my' => 'vimati 2',
+            'abbr_pts' => 'VmvT',
+            'abbr_wp' => 'vimati.ṭī.bhikkhunī.',
+        ],
+        1779 => [
+            'abbr_my' => 'vimati 2',
+            'abbr_pts' => 'VmvT',
+            'abbr_wp' => 'vimati.ṭī.mahāva.',
+        ],
+        2431 => [
+            'abbr_my' => 'vimati 2',
+            'abbr_pts' => 'VmvT',
+            'abbr_wp' => 'vimati.ṭī.cūḷava.',
+        ],
+        2792 => [
+            'abbr_my' => 'vimati 2',
+            'abbr_pts' => 'VmvT',
+            'abbr_wp' => 'vimati.ṭī.pari.',
+        ],
+    ],
+    211 => [
+        3 => [
+            'abbr_my' => 'vi.laṅ.-ṭī 1',
+            'abbr_pts' => 'Vinālk',
+            'abbr_wp' => 'vinayālaṅkāra.ṭī.',
+        ],
+    ],
+    212 => [
+        2 => [
+            'abbr_my' => 'kaṅkhā',
+            'abbr_pts' => 'KkhPT',
+            'abbr_wp' => 'kaṅkhā.purāṇaṭī.',
+        ],
+        671 => [
+            'abbr_my' => 'kaṅkhā',
+            'abbr_pts' => 'KkhAbhT',
+            'abbr_wp' => 'kaṅkhā.abhinavaṭī.',
+        ],
+    ],
+    213 => [
+        3 => [
+            'abbr_my' => 'vi 1',
+            'abbr_pts' => 'Vin III',
+            'abbr_wp' => 'pārā.',
+        ],
+    ],
+    214 => [
+        3 => [
+            'abbr_my' => 'vi 1',
+            'abbr_pts' => 'Vin IV',
+            'abbr_wp' => 'pāci.',
+        ],
+        2162 => [
+            'abbr_my' => 'vi 1',
+            'abbr_pts' => 'Vin IV',
+            'abbr_wp' => 'pāci.bhikkhunī.',
+        ],
+    ],
+    215 => [
+        3 => [
+            'abbr_my' => 'vi 2',
+            'abbr_pts' => 'Vin I',
+            'abbr_wp' => 'mahāva.',
+        ],
+    ],
+    216 => [
+        3 => [
+            'abbr_my' => 'vi 2',
+            'abbr_pts' => 'Vin II',
+            'abbr_wp' => 'cūḷava.',
+        ],
+    ],
+    217 => [
+        3 => [
+            'abbr_my' => 'vi 3',
+            'abbr_pts' => 'Vin V',
+            'abbr_wp' => 'pari.',
+        ],
+    ],
+];

+ 46 - 20
api-v13/tests/Feature/SearchPaliWbwRefTest.php

@@ -1,10 +1,13 @@
 <?php
 
 /**
- * search-pali-wbw 的资源里要带上 page_numbers 的页码引用。
+ * search-pali-wbw 的资源里要带上页码引用 ref
  *
  * 每个 (book, paragraph) 在 page_numbers 里可能因为 wid 不同而有多行,
- * 输出时每个 type 只保留 wid 最小的那一行,并且只暴露 type / page 两个字段。
+ * 输出时每个 type 只保留 wid 最小的那一行,并且暴露 type / page / title 三个字段;
+ * type 把单字母代号映射成缩写(M→My、P→PTS、V→VRI、T→Thai、O→Other),
+ * title 是书缩写:type 为 M 时取 abbr_my、为 P 时取 abbr_pts。
+ * 另外恒有一条 type='WP' 的条目,page 为段落号、title 为 abbr_wp。
  */
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Support\Facades\DB;
@@ -50,48 +53,71 @@ function paliTextRow(int $paragraph = 1): array
 /**
  * 造一行 page_numbers。
  */
-function pageNumberRow(string $type, int $wid, int $page): array
+function pageNumberRow(string $type, int $wid, int $page, int $paragraph = 1): array
 {
     return [
         'type' => $type,
         'volume' => 1,
         'page' => $page,
         'book' => 1,
-        'paragraph' => 1,
+        'paragraph' => $paragraph,
         'wid' => $wid,
         'pcd_book_id' => 1,
     ];
 }
 
 it('adds ref with the smallest wid page for each type', function () {
-    DB::table('wbw_templates')->insert([wbwRow()]);
-    DB::table('pali_texts')->insert([paliTextRow()]);
+    DB::table('wbw_templates')->insert([wbwRow(10)]);
+    DB::table('pali_texts')->insert([paliTextRow(10)]);
     DB::table('page_numbers')->insert([
-        pageNumberRow('a', 5, 111),
-        pageNumberRow('a', 2, 222),
-        pageNumberRow('a', 9, 333),
-        pageNumberRow('b', 4, 444),
-        pageNumberRow('b', 1, 555),
+        pageNumberRow('M', 5, 111, 10),
+        pageNumberRow('M', 2, 222, 10),
+        pageNumberRow('M', 9, 333, 10),
+        pageNumberRow('P', 4, 444, 10),
+        pageNumberRow('P', 1, 555, 10),
     ]);
 
     $response = $this->getJson('/api/v2/search-pali-wbw?key=dhammo')
         ->assertOk();
 
-    $ref = $response->json('data.rows.0.ref');
-    expect($ref)->toHaveCount(2);
+    $byType = collect($response->json('data.rows.0.ref'))->keyBy('type');
+
+    expect($byType->get('My'))->toBe(['type' => 'My', 'page' => 222, 'title' => 'namakkāra'])
+        ->and($byType->get('PTS'))->toBe(['type' => 'PTS', 'page' => 555, 'title' => 'Nam'])
+        ->and($byType->get('WP'))->toBe(['type' => 'WP', 'page' => 10, 'title' => 'namakkāra.']);
+});
+
+it('maps all page number types to their abbreviations', function () {
+    DB::table('wbw_templates')->insert([wbwRow(10)]);
+    DB::table('pali_texts')->insert([paliTextRow(10)]);
+    DB::table('page_numbers')->insert([
+        pageNumberRow('P', 1, 101, 10),
+        pageNumberRow('M', 2, 102, 10),
+        pageNumberRow('V', 3, 103, 10),
+        pageNumberRow('T', 4, 104, 10),
+        pageNumberRow('O', 5, 105, 10),
+    ]);
+
+    $response = $this->getJson('/api/v2/search-pali-wbw?key=dhammo')
+        ->assertOk();
 
-    $byType = collect($ref)->keyBy('type');
+    $byType = collect($response->json('data.rows.0.ref'))->keyBy('type');
 
-    expect($byType->get('a'))->toBe(['type' => 'a', 'page' => 222])
-        ->and($byType->get('b'))->toBe(['type' => 'b', 'page' => 555]);
+    expect($byType->get('PTS'))->toBe(['type' => 'PTS', 'page' => 101, 'title' => 'Nam'])
+        ->and($byType->get('My'))->toBe(['type' => 'My', 'page' => 102, 'title' => 'namakkāra'])
+        ->and($byType->get('VRI'))->toBe(['type' => 'VRI', 'page' => 103, 'title' => null])
+        ->and($byType->get('Thai'))->toBe(['type' => 'Thai', 'page' => 104, 'title' => null])
+        ->and($byType->get('Other'))->toBe(['type' => 'Other', 'page' => 105, 'title' => null]);
 });
 
-it('omits ref when there are no page_numbers rows', function () {
-    DB::table('wbw_templates')->insert([wbwRow()]);
-    DB::table('pali_texts')->insert([paliTextRow()]);
+it('adds the WP entry even when there are no page_numbers rows', function () {
+    DB::table('wbw_templates')->insert([wbwRow(10)]);
+    DB::table('pali_texts')->insert([paliTextRow(10)]);
 
     $response = $this->getJson('/api/v2/search-pali-wbw?key=dhammo')
         ->assertOk();
 
-    expect($response->json('data.rows.0'))->not->toHaveKey('ref');
+    expect($response->json('data.rows.0.ref'))->toBe([
+        ['type' => 'WP', 'page' => 10, 'title' => 'namakkāra.'],
+    ]);
 });