feat: add local hexagram content for offline reading
Load a versioned Wikisource jing plus project-authored plain drafts so results can show labeled original and vernacular texts without unauthorized modern translations. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { kingWenEntries, validateContentPackage } from "./content-contract.mjs";
|
||||
import { repositoryRoot } from "./repository-files.mjs";
|
||||
import { lingjiPlain } from "../content/plain/lingji-plain.mjs";
|
||||
|
||||
const names = [
|
||||
"乾", "坤", "屯", "蒙", "需", "讼", "师", "比",
|
||||
"小畜", "履", "泰", "否", "同人", "大有", "谦", "豫",
|
||||
"随", "蛊", "临", "观", "噬嗑", "贲", "剥", "复",
|
||||
"无妄", "大畜", "颐", "大过", "坎", "离", "咸", "恒",
|
||||
"遁", "大壮", "晋", "明夷", "家人", "睽", "蹇", "解",
|
||||
"损", "益", "夬", "姤", "萃", "升", "困", "井",
|
||||
"革", "鼎", "震", "艮", "渐", "归妹", "丰", "旅",
|
||||
"巽", "兑", "涣", "节", "中孚", "小过", "既济", "未济",
|
||||
];
|
||||
|
||||
if (lingjiPlain.length !== 64) {
|
||||
throw new Error(`lingji plain table must contain 64 hexagrams, got ${lingjiPlain.length}`);
|
||||
}
|
||||
|
||||
const jingPath = path.join(repositoryRoot, "content", "raw", "zhouyi-wikisource-jing.json");
|
||||
const jing = JSON.parse(await readFile(jingPath, "utf8"));
|
||||
if (!Array.isArray(jing) || jing.length !== 64) {
|
||||
throw new Error("Wikisource jing import must contain 64 hexagrams; run scripts/import-zhouyi-wikisource.mjs");
|
||||
}
|
||||
|
||||
const jingById = new Map(jing.map((item) => [item.kingWenNumber, item]));
|
||||
const entries = kingWenEntries();
|
||||
const originalSource = "wikisource-zhouyi-jing-zh-Hans";
|
||||
const plainSource = "lingji-plain-zh-Hans-2026.1";
|
||||
|
||||
const contentPackage = {
|
||||
schemaVersion: 1,
|
||||
contentVersion: "zh-Hans-2026.1",
|
||||
specialUsageTexts: { qian: true, kun: true },
|
||||
sources: [
|
||||
{
|
||||
id: originalSource,
|
||||
title: "维基文库《周易》易经",
|
||||
edition: "仅卦辞、爻辞与乾用九、坤用六,不含十翼;由 zh.wikisource.org 导入并经 Wikimedia zh-hans 转换",
|
||||
license: "公版(Wikimedia PD-old)",
|
||||
url: "https://zh.wikisource.org/wiki/周易",
|
||||
},
|
||||
{
|
||||
id: plainSource,
|
||||
title: "灵机本地白话",
|
||||
edition: "2026.1 项目自撰草稿,待内容负责人审校后视为可再分发",
|
||||
license: "项目自撰;审校完成前仅用于本机构建,不以现代译注冒充公版",
|
||||
url: "https://creativecommons.org/licenses/by/4.0/deed.zh-Hans",
|
||||
},
|
||||
],
|
||||
hexagrams: entries.map((entry) => {
|
||||
const original = jingById.get(entry.kingWenNumber);
|
||||
if (!original) {
|
||||
throw new Error(`Missing jing text for King Wen ${entry.kingWenNumber}`);
|
||||
}
|
||||
const plain = lingjiPlain[entry.kingWenNumber - 1];
|
||||
if (!Array.isArray(plain) || plain.length !== 7 || plain.some((text) => !String(text).trim())) {
|
||||
throw new Error(`Plain text for King Wen ${entry.kingWenNumber} must contain 7 non-blank strings`);
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
name: names[entry.kingWenNumber - 1],
|
||||
symbol: String.fromCodePoint(0x4DC0 + entry.kingWenNumber - 1),
|
||||
judgmentOriginal: original.judgmentOriginal,
|
||||
judgmentPlain: plain[0],
|
||||
lineTextsBottomUp: original.lineTextsBottomUp,
|
||||
linePlainBottomUp: plain.slice(1),
|
||||
specialUsageText: original.specialUsageText ?? null,
|
||||
sourceRefs: [originalSource, plainSource],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const errors = validateContentPackage(contentPackage);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Built content package failed validation:\n${errors.join("\n")}`);
|
||||
}
|
||||
|
||||
const outputDirectory = path.join(repositoryRoot, "content", "packages");
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const outputPath = path.join(outputDirectory, "hexagram-content.json");
|
||||
await writeFile(outputPath, `${JSON.stringify(contentPackage, null, 2)}\n`);
|
||||
process.stdout.write(`wrote ${outputPath}\n`);
|
||||
@@ -0,0 +1,221 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { kingWenEntries } from "./content-contract.mjs";
|
||||
import { repositoryRoot } from "./repository-files.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || "http://127.0.0.1:1080";
|
||||
const limit = Number.parseInt(process.env.IMPORT_LIMIT ?? "64", 10);
|
||||
const userAgent = "LingjiContentImport/1.0 (local Zhouyi pipeline; public-domain jingwen only)";
|
||||
const outputDirectory = path.join(repositoryRoot, "content", "raw");
|
||||
const outputPath = path.join(outputDirectory, "zhouyi-wikisource-jing.json");
|
||||
|
||||
const titles = [
|
||||
"乾", "坤", "屯", "蒙", "需", "訟", "師", "比",
|
||||
"小畜", "履", "泰", "否", "同人", "大有", "謙", "豫",
|
||||
"隨", "蠱", "臨", "觀", "噬嗑", "賁", "剝", "復",
|
||||
"无妄", "大畜", "頤", "大過", "坎", "離", "咸", "恆",
|
||||
"遯", "大壯", "晉", "明夷", "家人", "睽", "蹇", "解",
|
||||
"損", "益", "夬", "姤", "萃", "升", "困", "井",
|
||||
"革", "鼎", "震", "艮", "漸", "歸妹", "豐", "旅",
|
||||
"巽", "兌", "渙", "節", "中孚", "小過", "既濟", "未濟",
|
||||
];
|
||||
|
||||
const linePattern = /^(初九|九二|九三|九四|九五|上九|初六|六二|六三|六四|六五|上六|用九|用六)[::,,](.*)$/u;
|
||||
|
||||
function stripMarkup(wikitext) {
|
||||
return wikitext
|
||||
.replace(/-\{([^}|]+)-\}/gu, "$1")
|
||||
.replace(/\{\{[^}]*\}\}/gu, "")
|
||||
.replace(/<[^>]+>/gu, "")
|
||||
.replace(/'{2,}/gu, "")
|
||||
.replace(/\[\[File:[^\]]*\]\]/gu, "")
|
||||
.replace(/\[\[(?:[^\|\]]*\|)?([^\]]+)\]\]/gu, "$1")
|
||||
.replace(/ /gu, " ")
|
||||
.replace(/\r/gu, "");
|
||||
}
|
||||
|
||||
function parseClassicFields(section) {
|
||||
const rawLines = section
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^[ *#:]+/u, "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let judgment = "";
|
||||
const lines = [];
|
||||
let special = null;
|
||||
for (const rawLine of rawLines) {
|
||||
const compact = rawLine.replace(/\s+/gu, "");
|
||||
const hit = linePattern.exec(compact) ?? linePattern.exec(rawLine);
|
||||
if (hit) {
|
||||
const label = hit[1];
|
||||
const text = `${label}:${hit[2].trim()}`;
|
||||
if (label === "用九" || label === "用六") {
|
||||
special = text;
|
||||
} else {
|
||||
lines.push(text);
|
||||
}
|
||||
} else if (lines.length === 0 && special == null) {
|
||||
const fragment = rawLine.replace(/\s+/gu, "");
|
||||
judgment = judgment ? `${judgment}${fragment}` : fragment;
|
||||
}
|
||||
}
|
||||
|
||||
judgment = judgment
|
||||
.replace(/^(?:周易)?[\u4e00-\u9fff]{1,3}[::]/u, "")
|
||||
.trim();
|
||||
if (!judgment) throw new Error("missing judgment");
|
||||
if (lines.length !== 6) {
|
||||
throw new Error(`expected 6 lines, got ${lines.length}: ${lines.join(" | ")}`);
|
||||
}
|
||||
return { judgmentOriginal: judgment, lineTextsBottomUp: lines, specialUsageText: special };
|
||||
}
|
||||
|
||||
function extractClassicSection(wikitext) {
|
||||
const normalized = stripMarkup(wikitext).replace(/\u3000/gu, " ");
|
||||
const classicIndex = normalized.search(/易[經经][::]/u);
|
||||
if (classicIndex < 0) throw new Error("missing 易經 section");
|
||||
const afterClassic = normalized.slice(classicIndex);
|
||||
const stop = afterClassic.search(/\n[ *#]*彖曰[::]/u);
|
||||
return (stop >= 0 ? afterClassic.slice(0, stop) : afterClassic)
|
||||
.replace(/^易[經经][::]\s*/u, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function stripHtml(value) {
|
||||
return value
|
||||
.replace(/<[^>]+>/gu, "")
|
||||
.replace(/"/gu, "\"")
|
||||
.replace(/&/gu, "&")
|
||||
.replace(/</gu, "<")
|
||||
.replace(/>/gu, ">")
|
||||
.replace(/\n+/gu, "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function curlJson(args) {
|
||||
const { stdout } = await execFileAsync("curl.exe", [
|
||||
"-sS",
|
||||
"--fail",
|
||||
"--retry",
|
||||
"12",
|
||||
"--retry-delay",
|
||||
"20",
|
||||
"--retry-all-errors",
|
||||
"--max-time",
|
||||
"45",
|
||||
"-x",
|
||||
proxyUrl,
|
||||
"-H",
|
||||
`User-Agent: ${userAgent}`,
|
||||
"-H",
|
||||
"Accept: application/json",
|
||||
...args,
|
||||
], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
const titleFallbacks = {
|
||||
恆: ["恒"],
|
||||
遯: ["遁"],
|
||||
晉: ["晋"],
|
||||
大壯: ["大壮"],
|
||||
歸妹: ["归妹"],
|
||||
豐: ["丰"],
|
||||
兌: ["兑"],
|
||||
渙: ["涣"],
|
||||
節: ["节"],
|
||||
既濟: ["既济"],
|
||||
未濟: ["未济"],
|
||||
};
|
||||
|
||||
async function fetchWikitext(title) {
|
||||
const candidates = [title, ...(titleFallbacks[title] ?? [])];
|
||||
for (const candidate of candidates) {
|
||||
const url = new URL("https://zh.wikisource.org/w/api.php");
|
||||
url.searchParams.set("action", "parse");
|
||||
url.searchParams.set("page", `周易/${candidate}`);
|
||||
url.searchParams.set("prop", "wikitext");
|
||||
url.searchParams.set("format", "json");
|
||||
const payload = await curlJson([url.toString()]);
|
||||
const wikitext = payload?.parse?.wikitext?.["*"];
|
||||
if (typeof wikitext === "string") return wikitext;
|
||||
process.stdout.write(`missing ${candidate}, trying fallback\n`);
|
||||
}
|
||||
throw new Error(`no wikitext for ${title}`);
|
||||
}
|
||||
|
||||
async function toHansFields(classic) {
|
||||
const pieces = [
|
||||
classic.judgmentOriginal,
|
||||
...classic.lineTextsBottomUp,
|
||||
...(classic.specialUsageText == null ? [] : [classic.specialUsageText]),
|
||||
];
|
||||
const converted = await convertToHans(pieces.join("\n¶\n"));
|
||||
const parts = converted
|
||||
.split("¶")
|
||||
.map((part) => part.replace(/\s+/gu, "").trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length !== pieces.length) {
|
||||
throw new Error(`zh-hans field count ${parts.length} != ${pieces.length}: ${converted}`);
|
||||
}
|
||||
return {
|
||||
judgmentOriginal: parts[0],
|
||||
lineTextsBottomUp: parts.slice(1, 7),
|
||||
specialUsageText: classic.specialUsageText == null ? null : parts[7],
|
||||
};
|
||||
}
|
||||
async function convertToHans(text) {
|
||||
const payload = await curlJson([
|
||||
"--data-urlencode", "action=parse",
|
||||
"--data-urlencode", `text=${text}`,
|
||||
"--data-urlencode", "prop=text",
|
||||
"--data-urlencode", "variant=zh-hans",
|
||||
"--data-urlencode", "disablelimitreport=1",
|
||||
"--data-urlencode", "wrapoutputclass=",
|
||||
"--data-urlencode", "contentmodel=wikitext",
|
||||
"--data-urlencode", "format=json",
|
||||
"https://zh.wikisource.org/w/api.php",
|
||||
]);
|
||||
const html = payload?.parse?.text?.["*"];
|
||||
if (typeof html !== "string") throw new Error("zh-hans conversion returned no text");
|
||||
return stripHtml(html);
|
||||
}
|
||||
|
||||
const entries = kingWenEntries();
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
let imported = [];
|
||||
try {
|
||||
imported = JSON.parse(await readFile(outputPath, "utf8"));
|
||||
if (!Array.isArray(imported)) imported = [];
|
||||
} catch {
|
||||
imported = [];
|
||||
}
|
||||
const done = new Set(imported.map((item) => item.kingWenNumber));
|
||||
|
||||
for (const [index, title] of titles.entries()) {
|
||||
if (index >= limit) break;
|
||||
const kingWenNumber = index + 1;
|
||||
if (done.has(kingWenNumber)) {
|
||||
process.stdout.write(`skip ${kingWenNumber}/64 ${title}\n`);
|
||||
continue;
|
||||
}
|
||||
const wikitext = await fetchWikitext(title);
|
||||
const traditional = extractClassicSection(wikitext);
|
||||
const classic = await toHansFields(parseClassicFields(traditional));
|
||||
imported.push({
|
||||
kingWenNumber,
|
||||
wikisourceTitle: title,
|
||||
sourcePage: `https://zh.wikisource.org/wiki/周易/${title}`,
|
||||
...entries[index],
|
||||
...classic,
|
||||
});
|
||||
imported.sort((left, right) => left.kingWenNumber - right.kingWenNumber);
|
||||
await writeFile(outputPath, `${JSON.stringify(imported, null, 2)}\n`);
|
||||
process.stdout.write(`imported ${kingWenNumber}/64 ${title}\n`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
}
|
||||
|
||||
process.stdout.write(`wrote ${outputPath} (${imported.length} hexagrams)\n`);
|
||||
@@ -104,3 +104,52 @@ for (const [name, mutate, expected] of negativeCases) {
|
||||
console.log(
|
||||
`Content contract verification passed (64 entries cross-checked with Kotlin, ${negativeCases.length} negative fixtures, digest ${digest.slice(0, 12)}…).`,
|
||||
);
|
||||
|
||||
const packagePath = path.join(repositoryRoot, "content", "packages", "hexagram-content.json");
|
||||
const namesPath = path.join(
|
||||
repositoryRoot,
|
||||
"app",
|
||||
"src",
|
||||
"main",
|
||||
"java",
|
||||
"net",
|
||||
"opcapp",
|
||||
"flash",
|
||||
"core",
|
||||
"model",
|
||||
"HexagramNames.kt",
|
||||
);
|
||||
const namesSource = await readFile(namesPath, "utf8");
|
||||
const namesBlock = namesSource.match(/private val names = listOf\(([\s\S]*?)\)/u);
|
||||
if (!namesBlock) {
|
||||
throw new Error("HexagramNames.kt name table could not be parsed");
|
||||
}
|
||||
const catalogNames = [...namesBlock[1].matchAll(/"([^"]+)"/gu)].map((match) => match[1]);
|
||||
if (catalogNames.length !== 64) {
|
||||
throw new Error("HexagramNames.kt must contain 64 names");
|
||||
}
|
||||
|
||||
const published = JSON.parse(await readFile(packagePath, "utf8"));
|
||||
const publishedErrors = validateContentPackage(published);
|
||||
if (publishedErrors.length > 0) {
|
||||
throw new Error(`Published content package failed validation:\n${publishedErrors.join("\n")}`);
|
||||
}
|
||||
published.hexagrams.forEach((hexagram) => {
|
||||
const expectedName = catalogNames[hexagram.kingWenNumber - 1];
|
||||
const expectedSymbol = String.fromCodePoint(0x4DC0 + hexagram.kingWenNumber - 1);
|
||||
if (hexagram.name !== expectedName) {
|
||||
throw new Error(`Published package name for ${hexagram.kingWenNumber} must be ${expectedName}`);
|
||||
}
|
||||
if (hexagram.symbol !== expectedSymbol) {
|
||||
throw new Error(`Published package symbol for ${hexagram.kingWenNumber} must be ${expectedSymbol}`);
|
||||
}
|
||||
});
|
||||
if (published.contentVersion !== "zh-Hans-2026.1") {
|
||||
throw new Error("Published contentVersion must be zh-Hans-2026.1");
|
||||
}
|
||||
if (!published.specialUsageTexts.qian || !published.specialUsageTexts.kun) {
|
||||
throw new Error("Published package must include Qian 用九 and Kun 用六");
|
||||
}
|
||||
console.log(
|
||||
`Published content package ${published.contentVersion} passed (digest ${contentDigest(published).slice(0, 12)}…).`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user