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:
QiuSW
2026-08-19 11:48:16 +08:00
co-authored by Cursor
parent 4235cf9011
commit 534c88993e
33 changed files with 5626 additions and 29 deletions
+5
View File
@@ -57,6 +57,11 @@ android {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
} }
sourceSets {
getByName("main").assets.srcDir(rootProject.file("content/packages"))
getByName("test").resources.srcDir(rootProject.file("content/packages"))
}
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
} }
@@ -76,14 +76,33 @@ class MainActivityTest {
fun autoSaveSettingIsRealAndReflectedOnHome() { fun autoSaveSettingIsRealAndReflectedOnHome() {
openHome() openHome()
composeRule.onNodeWithText("管理保存设置").performClick() composeRule.onNodeWithText("管理保存设置").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag("auto_save_history").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("auto_save_history").assertIsOn().performClick() composeRule.onNodeWithTag("auto_save_history").assertIsOn().performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
runCatching {
composeRule.onNodeWithTag("auto_save_history").assertIsOff() composeRule.onNodeWithTag("auto_save_history").assertIsOff()
true
}.getOrDefault(false)
}
composeRule.onNodeWithText("返回").performClick() composeRule.onNodeWithText("返回").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("自动保存完整记录:已关闭").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithText("自动保存完整记录:已关闭").assertIsDisplayed() composeRule.onNodeWithText("自动保存完整记录:已关闭").assertIsDisplayed()
composeRule.onNodeWithText("管理保存设置").performClick() composeRule.onNodeWithText("管理保存设置").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag("auto_save_history").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("auto_save_history").performClick() composeRule.onNodeWithTag("auto_save_history").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
runCatching {
composeRule.onNodeWithTag("auto_save_history").assertIsOn() composeRule.onNodeWithTag("auto_save_history").assertIsOn()
true
}.getOrDefault(false)
}
} }
@Test @Test
@@ -12,6 +12,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import net.opcapp.flash.feature.casting.CastingScreen import net.opcapp.flash.feature.casting.CastingScreen
import net.opcapp.flash.feature.casting.CastingViewModel import net.opcapp.flash.feature.casting.CastingViewModel
import net.opcapp.flash.feature.content.ContentSourcesScreen
import net.opcapp.flash.feature.home.HomeScreen import net.opcapp.flash.feature.home.HomeScreen
import net.opcapp.flash.feature.home.HomeViewModel import net.opcapp.flash.feature.home.HomeViewModel
import net.opcapp.flash.feature.onboarding.MethodIntroScreen import net.opcapp.flash.feature.onboarding.MethodIntroScreen
@@ -27,6 +28,7 @@ private object Destination {
const val Casting = "casting" const val Casting = "casting"
const val Result = "result" const val Result = "result"
const val Settings = "settings" const val Settings = "settings"
const val Sources = "sources"
} }
@Composable @Composable
@@ -78,6 +80,7 @@ fun LingjiApp(
}, },
onOpenSettings = { navController.navigate(Destination.Settings) }, onOpenSettings = { navController.navigate(Destination.Settings) },
onOpenMethodIntro = { navController.navigate(Destination.Welcome) }, onOpenMethodIntro = { navController.navigate(Destination.Welcome) },
onOpenContentSources = { navController.navigate(Destination.Sources) },
) )
} }
composable(Destination.Question) { composable(Destination.Question) {
@@ -127,5 +130,11 @@ fun LingjiApp(
onBack = navController::popBackStack, onBack = navController::popBackStack,
) )
} }
composable(Destination.Sources) {
ContentSourcesScreen(
manifest = homeState.contentManifest,
onBack = navController::popBackStack,
)
}
} }
} }
@@ -0,0 +1,34 @@
package net.opcapp.flash.core.model
data class ContentSource(
val id: String,
val title: String,
val edition: String,
val license: String,
val url: String,
) {
init {
require(id.isNotBlank()) { "Source id must not be blank" }
require(title.isNotBlank()) { "Source title must not be blank" }
require(edition.isNotBlank()) { "Source edition must not be blank" }
require(license.isNotBlank()) { "Source license must not be blank" }
require(url.isNotBlank()) { "Source url must not be blank" }
}
}
data class ContentManifest(
val contentVersion: String,
val digest: String,
val sources: List<ContentSource>,
val loadError: String? = null,
) {
init {
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
require(loadError == null || loadError.isNotBlank()) {
"Content load error must be non-blank or null"
}
require(sources.map(ContentSource::id).distinct().size == sources.size) {
"Content sources must have unique ids"
}
}
}
@@ -0,0 +1,55 @@
package net.opcapp.flash.data.content
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.core.model.HexagramContent
@Singleton
class AssetsHexagramContentRepository @Inject constructor(
@ApplicationContext context: Context,
) : HexagramContentRepository {
private val parsed: Result<ParsedHexagramContentPackage> by lazy {
runCatching {
context.assets.open(ASSET_NAME).bufferedReader(Charsets.UTF_8).use { reader ->
HexagramContentParser.parse(reader.readText())
}
}
}
override val contentVersion: String
get() = parsed.getOrNull()?.manifest?.contentVersion ?: UNAVAILABLE_VERSION
override val manifest: ContentManifest
get() = parsed.getOrNull()?.manifest ?: ContentManifest(
contentVersion = UNAVAILABLE_VERSION,
digest = "unavailable",
sources = emptyList(),
loadError = parsed.exceptionOrNull()?.message ?: "Content package could not be loaded",
)
override fun contentFor(
kingWenNumber: Int,
requestedContentVersion: String,
): HexagramContent {
val pack = parsed.getOrElse { error ->
throw ContentIntegrityException(
error.message ?: "Content package failed integrity checks",
)
}
if (requestedContentVersion != pack.manifest.contentVersion) {
throw ContentIntegrityException(
"Requested content version '$requestedContentVersion' is unavailable; loaded '${pack.manifest.contentVersion}'",
)
}
return pack.hexagrams[kingWenNumber]
?: throw ContentIntegrityException("No validated content for King Wen number $kingWenNumber")
}
companion object {
const val ASSET_NAME = "hexagram-content.json"
const val UNAVAILABLE_VERSION = "unavailable"
}
}
@@ -0,0 +1,340 @@
package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.core.model.ContentSource
import net.opcapp.flash.core.model.HexagramContent
import net.opcapp.flash.core.model.HexagramNames
import net.opcapp.flash.domain.casting.HexagramCatalog
import net.opcapp.flash.domain.casting.HexagramPattern
import net.opcapp.flash.domain.casting.Polarity
data class ParsedHexagramContentPackage(
val manifest: ContentManifest,
val hexagrams: Map<Int, HexagramContent>,
)
object HexagramContentParser {
private val unsafeText =
Regex("(?:<\\s*script\\b|javascript\\s*:|\\bon\\w+\\s*=|[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f])")
fun parse(json: String): ParsedHexagramContentPackage {
val root = try {
JsonReader(json).readValue() as? JsonValue.Obj
?: throw ContentIntegrityException("Content package must be a JSON object")
} catch (error: ContentIntegrityException) {
throw error
} catch (error: IllegalStateException) {
throw ContentIntegrityException(error.message ?: "Content package JSON is invalid")
}
val errors = mutableListOf<String>()
val schemaVersion = root.int("schemaVersion", errors)
if (schemaVersion != 1) errors += "schemaVersion must be 1"
val contentVersion = root.text("contentVersion", errors)
val usage = root.obj("specialUsageTexts", errors)
val qianUsage = usage?.bool("qian", errors)
val kunUsage = usage?.bool("kun", errors)
val sourceNodes = root.arr("sources", errors)
val sources = mutableListOf<ContentSource>()
val sourceIds = mutableSetOf<String>()
sourceNodes?.forEachIndexed { index, node ->
val prefix = "sources[$index]"
val obj = node as? JsonValue.Obj
if (obj == null) {
errors += "$prefix must be an object"
return@forEachIndexed
}
val id = obj.text("id", errors, prefix)
val title = obj.text("title", errors, prefix)
val edition = obj.text("edition", errors, prefix)
val license = obj.text("license", errors, prefix)
val url = obj.text("url", errors, prefix)
if (id != null && !sourceIds.add(id)) errors += "$prefix.id must be unique"
if (url != null && !isHttpUrl(url)) errors += "$prefix.url must be an absolute HTTP(S) URL"
if (id != null && title != null && edition != null && license != null && url != null) {
sources += ContentSource(id, title, edition, license, url)
}
}
if (sourceNodes != null && sourceNodes.isEmpty()) {
errors += "sources must contain at least one licensed source"
}
val hexagramNodes = root.arr("hexagrams", errors)
if (hexagramNodes != null && hexagramNodes.size != 64) {
errors += "hexagrams must contain exactly 64 entries"
}
val hexagrams = mutableMapOf<Int, HexagramContent>()
val seenPatterns = mutableSetOf<String>()
hexagramNodes?.forEachIndexed { index, node ->
val prefix = "hexagrams[$index]"
val obj = node as? JsonValue.Obj
if (obj == null) {
errors += "$prefix must be an object"
return@forEachIndexed
}
val kingWenNumber = obj.int("kingWenNumber", errors, prefix)
val name = obj.text("name", errors, prefix)
val symbol = obj.text("symbol", errors, prefix)
val judgmentOriginal = obj.text("judgmentOriginal", errors, prefix)
val judgmentPlain = obj.text("judgmentPlain", errors, prefix)
val lineTexts = obj.sixTexts("lineTextsBottomUp", errors, prefix)
val linePlain = obj.sixTexts("linePlainBottomUp", errors, prefix)
val sourceRefs = obj.stringList("sourceRefs", errors, prefix)
val specialUsage = obj.optionalText("specialUsageText", errors, prefix)
val lower = obj.text("lowerTrigram", errors, prefix)
val upper = obj.text("upperTrigram", errors, prefix)
val patternTokens = obj.stringList("patternBottomUp", errors, prefix)
if (kingWenNumber != null && (kingWenNumber < 1 || kingWenNumber > 64)) {
errors += "$prefix.kingWenNumber must be an integer from 1 through 64"
}
if (kingWenNumber != null && hexagrams.containsKey(kingWenNumber)) {
errors += "$prefix.kingWenNumber must be unique"
}
if (kingWenNumber != null && name != null && name != HexagramNames.nameFor(kingWenNumber)) {
errors += "$prefix.name does not match the King Wen name table"
}
if (kingWenNumber != null && symbol != null && symbol != hexagramSymbol(kingWenNumber)) {
errors += "$prefix.symbol must be the King Wen unicode hexagram"
}
if (sourceRefs != null) {
if (sourceRefs.isEmpty()) errors += "$prefix.sourceRefs must not be empty"
if (sourceRefs.size != sourceRefs.distinct().size) {
errors += "$prefix.sourceRefs must be unique"
}
for (sourceRef in sourceRefs) {
if (sourceRef !in sourceIds) {
errors += "$prefix.sourceRefs contains unknown source '$sourceRef'"
}
}
}
if (patternTokens != null) {
if (patternTokens.size != 6 || patternTokens.any { it != "YIN" && it != "YANG" }) {
errors += "$prefix.patternBottomUp must contain exactly six YIN/YANG values"
} else {
val encoded = patternTokens.joinToString("/")
if (!seenPatterns.add(encoded)) errors += "$prefix.patternBottomUp must be unique"
val polarities = patternTokens.map { token ->
if (token == "YANG") Polarity.YANG else Polarity.YIN
}
val expectedId = HexagramCatalog.idFor(HexagramPattern.of(polarities)).value
if (kingWenNumber != null && kingWenNumber != expectedId) {
errors += "$prefix.kingWenNumber does not match its King Wen trigram pair"
}
val expectedPattern = expectedBottomUp(lower, upper)
if (expectedPattern != null && expectedPattern != patternTokens) {
errors += "$prefix.patternBottomUp does not match lower/upper trigrams in bottom-up order"
}
}
}
val hasSpecial = specialUsage != null
when (kingWenNumber) {
1 -> if (qianUsage != null && hasSpecial != qianUsage) {
errors += "$prefix.specialUsageText must match specialUsageTexts.qian"
}
2 -> if (kunUsage != null && hasSpecial != kunUsage) {
errors += "$prefix.specialUsageText must match specialUsageTexts.kun"
}
else -> if (hasSpecial) {
errors += "$prefix.specialUsageText is only valid for Qian or Kun"
}
}
if (
errors.none { it.startsWith(prefix) } &&
kingWenNumber != null &&
name != null &&
symbol != null &&
judgmentOriginal != null &&
judgmentPlain != null &&
lineTexts != null &&
linePlain != null &&
sourceRefs != null
) {
hexagrams[kingWenNumber] = HexagramContent(
kingWenNumber = kingWenNumber,
name = name,
symbol = symbol,
judgmentOriginal = judgmentOriginal,
judgmentPlain = judgmentPlain,
lineTextsBottomUp = lineTexts,
linePlainBottomUp = linePlain,
specialUsageText = specialUsage,
sourceRefs = sourceRefs,
)
}
}
if (hexagrams.size != 64 && errors.none { it.contains("exactly 64") }) {
errors += "King Wen numbers 1 through 64 must each occur exactly once"
}
if (errors.isNotEmpty()) {
throw ContentIntegrityException(errors.joinToString(separator = "\n"))
}
return ParsedHexagramContentPackage(
manifest = ContentManifest(
contentVersion = checkNotNull(contentVersion),
digest = jsonDigest(root),
sources = sources,
),
hexagrams = hexagrams,
)
}
private fun hexagramSymbol(kingWenNumber: Int): String =
(0x4DC0 + kingWenNumber - 1).toChar().toString()
private fun expectedBottomUp(lower: String?, upper: String?): List<String>? {
val lowerPattern = TRIGRAMS[lower] ?: return null
val upperPattern = TRIGRAMS[upper] ?: return null
return lowerPattern + upperPattern
}
private fun isHttpUrl(value: String): Boolean =
value.startsWith("https://") || value.startsWith("http://")
private fun JsonValue.Obj.int(key: String, errors: MutableList<String>, prefix: String = ""): Int? {
val path = fieldPath(prefix, key)
return when (val value = fields[key]) {
is JsonValue.Num -> value.value
null -> {
errors += "$path is missing"
null
}
else -> {
errors += "$path must be an integer"
null
}
}
}
private fun JsonValue.Obj.bool(key: String, errors: MutableList<String>, prefix: String = ""): Boolean? {
val path = fieldPath(prefix, key)
return when (val value = fields[key]) {
is JsonValue.Bool -> value.value
null -> {
errors += "$path is missing"
null
}
else -> {
errors += "$path must be a boolean"
null
}
}
}
private fun JsonValue.Obj.text(key: String, errors: MutableList<String>, prefix: String = ""): String? {
val path = fieldPath(prefix, key)
val value = optionalText(key, errors, prefix)
if (value == null && fields[key] !is JsonValue.Null) {
if (!fields.containsKey(key)) errors += "$path must be non-blank text"
} else if (value == null) {
errors += "$path must be non-blank text"
}
return value
}
private fun JsonValue.Obj.optionalText(
key: String,
errors: MutableList<String>,
prefix: String,
): String? {
val path = fieldPath(prefix, key)
return when (val value = fields[key]) {
JsonValue.Null, null -> null
is JsonValue.Str -> {
when {
value.value.isBlank() -> {
errors += "$path must be non-blank text or null"
null
}
unsafeText.containsMatchIn(value.value) -> {
errors += "$path contains script-like markup or an invisible control character"
null
}
else -> value.value
}
}
else -> {
errors += "$path must be non-blank text or null"
null
}
}
}
private fun JsonValue.Obj.obj(key: String, errors: MutableList<String>): JsonValue.Obj? {
return when (val value = fields[key]) {
is JsonValue.Obj -> value
else -> {
errors += "$key must be an object"
null
}
}
}
private fun JsonValue.Obj.arr(key: String, errors: MutableList<String>): List<JsonValue>? {
return when (val value = fields[key]) {
is JsonValue.Arr -> value.items
else -> {
errors += "$key must be an array"
null
}
}
}
private fun JsonValue.Obj.sixTexts(
key: String,
errors: MutableList<String>,
prefix: String,
): List<String>? {
val values = stringList(key, errors, prefix) ?: return null
if (values.size != 6) {
errors += "${fieldPath(prefix, key)} must contain exactly six bottom-up entries"
return null
}
return values
}
private fun JsonValue.Obj.stringList(
key: String,
errors: MutableList<String>,
prefix: String,
): List<String>? {
val path = fieldPath(prefix, key)
val array = fields[key] as? JsonValue.Arr
if (array == null) {
errors += "$path must be an array"
return null
}
val values = mutableListOf<String>()
array.items.forEachIndexed { index, item ->
val itemPath = "$path[$index]"
val text = (item as? JsonValue.Str)?.value
when {
text == null || text.isBlank() -> errors += "$itemPath must be non-blank text"
unsafeText.containsMatchIn(text) ->
errors += "$itemPath contains script-like markup or an invisible control character"
else -> values += text
}
}
return values.takeIf { it.size == array.items.size }
}
private fun fieldPath(prefix: String, key: String): String =
if (prefix.isEmpty()) key else "$prefix.$key"
private val TRIGRAMS = mapOf(
"QIAN" to listOf("YANG", "YANG", "YANG"),
"DUI" to listOf("YANG", "YANG", "YIN"),
"LI" to listOf("YANG", "YIN", "YANG"),
"ZHEN" to listOf("YANG", "YIN", "YIN"),
"XUN" to listOf("YIN", "YANG", "YANG"),
"KAN" to listOf("YIN", "YANG", "YIN"),
"GEN" to listOf("YIN", "YIN", "YANG"),
"KUN" to listOf("YIN", "YIN", "YIN"),
)
}
@@ -1,9 +1,11 @@
package net.opcapp.flash.data.content package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.core.model.HexagramContent import net.opcapp.flash.core.model.HexagramContent
interface HexagramContentRepository { interface HexagramContentRepository {
val contentVersion: String val contentVersion: String
val manifest: ContentManifest
fun contentFor( fun contentFor(
kingWenNumber: Int, kingWenNumber: Int,
@@ -0,0 +1,202 @@
package net.opcapp.flash.data.content
import java.security.MessageDigest
internal sealed interface JsonValue {
data class Obj(val fields: Map<String, JsonValue>) : JsonValue
data class Arr(val items: List<JsonValue>) : JsonValue
data class Str(val value: String) : JsonValue
data class Num(val value: Int) : JsonValue
data class Bool(val value: Boolean) : JsonValue
data object Null : JsonValue
}
internal class JsonReader(private val text: String) {
private var index = 0
fun readValue(): JsonValue {
skipWhitespace()
val value = parseValue()
skipWhitespace()
if (index != text.length) {
error("Unexpected trailing content at index $index")
}
return value
}
private fun parseValue(): JsonValue {
skipWhitespace()
return when (val char = peek()) {
'{' -> parseObject()
'[' -> parseArray()
'"' -> JsonValue.Str(parseString())
't' -> parseLiteral("true", JsonValue.Bool(true))
'f' -> parseLiteral("false", JsonValue.Bool(false))
'n' -> parseLiteral("null", JsonValue.Null)
'-', in '0'..'9' -> parseNumber()
else -> error("Unexpected character '$char' at index $index")
}
}
private fun parseObject(): JsonValue.Obj {
expect('{')
skipWhitespace()
val fields = linkedMapOf<String, JsonValue>()
if (peek() == '}') {
index += 1
return JsonValue.Obj(fields)
}
while (true) {
skipWhitespace()
val key = parseString()
skipWhitespace()
expect(':')
skipWhitespace()
if (fields.containsKey(key)) error("Duplicate JSON key '$key'")
fields[key] = parseValue()
skipWhitespace()
when (peek()) {
',' -> index += 1
'}' -> {
index += 1
return JsonValue.Obj(fields)
}
else -> error("Expected comma or object end at index $index")
}
}
}
private fun parseArray(): JsonValue.Arr {
expect('[')
skipWhitespace()
val items = mutableListOf<JsonValue>()
if (peek() == ']') {
index += 1
return JsonValue.Arr(items)
}
while (true) {
items += parseValue()
skipWhitespace()
when (peek()) {
',' -> index += 1
']' -> {
index += 1
return JsonValue.Arr(items)
}
else -> error("Expected comma or array end at index $index")
}
}
}
private fun parseString(): String {
expect('"')
val builder = StringBuilder()
while (true) {
val char = next()
when (char) {
'"' -> return builder.toString()
'\\' -> builder.append(parseEscape())
in '\u0000'..'\u001F' -> error("Unescaped control character in string")
else -> builder.append(char)
}
}
}
private fun parseEscape(): Char {
return when (val char = next()) {
'"', '\\', '/' -> char
'b' -> '\b'
'f' -> '\u000C'
'n' -> '\n'
'r' -> '\r'
't' -> '\t'
'u' -> {
val hex = CharArray(4) { next() }.concatToString()
hex.toIntOrNull(16)?.toChar() ?: error("Invalid unicode escape \\u$hex")
}
else -> error("Invalid string escape \\$char")
}
}
private fun parseNumber(): JsonValue.Num {
val start = index
if (peek() == '-') index += 1
if (peek() == '0') {
index += 1
} else {
if (peek() !in '1'..'9') error("Invalid number at index $start")
while (peek() in '0'..'9') index += 1
}
if (peek() == '.' || peek() == 'e' || peek() == 'E') {
error("Content JSON numbers must be integers")
}
return JsonValue.Num(text.substring(start, index).toInt())
}
private fun parseLiteral(literal: String, value: JsonValue): JsonValue {
if (!text.startsWith(literal, index)) error("Expected '$literal' at index $index")
index += literal.length
return value
}
private fun skipWhitespace() {
while (index < text.length && text[index] in WHITESPACE) index += 1
}
private fun peek(): Char {
if (index >= text.length) error("Unexpected end of JSON")
return text[index]
}
private fun next(): Char {
val char = peek()
index += 1
return char
}
private fun expect(char: Char) {
if (next() != char) error("Expected '$char' at index ${index - 1}")
}
companion object {
private val WHITESPACE = charArrayOf(' ', '\n', '\r', '\t')
}
}
internal fun jsonCanonicalize(value: JsonValue): String = when (value) {
JsonValue.Null -> "null"
is JsonValue.Bool -> value.value.toString()
is JsonValue.Num -> value.value.toString()
is JsonValue.Str -> jsonQuote(value.value)
is JsonValue.Arr -> value.items.joinToString(",", "[", "]") { jsonCanonicalize(it) }
is JsonValue.Obj -> value.fields.keys.sorted().joinToString(",", "{", "}") { key ->
"${jsonQuote(key)}:${jsonCanonicalize(value.fields.getValue(key))}"
}
}
internal fun jsonDigest(value: JsonValue): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(jsonCanonicalize(value).toByteArray(Charsets.UTF_8))
return digest.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xFF) }
}
private fun jsonQuote(value: String): String {
val builder = StringBuilder("\"")
for (char in value) {
when (char) {
'"' -> builder.append("\\\"")
'\\' -> builder.append("\\\\")
'\b' -> builder.append("\\b")
'\u000C' -> builder.append("\\f")
'\n' -> builder.append("\\n")
'\r' -> builder.append("\\r")
'\t' -> builder.append("\\t")
else -> if (char.code < 0x20) {
builder.append("\\u%04x".format(char.code))
} else {
builder.append(char)
}
}
}
return builder.append('"').toString()
}
@@ -0,0 +1,19 @@
package net.opcapp.flash.data.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import net.opcapp.flash.data.content.AssetsHexagramContentRepository
import net.opcapp.flash.data.content.HexagramContentRepository
@Module
@InstallIn(SingletonComponent::class)
abstract class ContentBindingsModule {
@Binds
@Singleton
abstract fun bindHexagramContentRepository(
implementation: AssetsHexagramContentRepository,
): HexagramContentRepository
}
@@ -14,6 +14,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.opcapp.flash.core.model.HexagramContent
import net.opcapp.flash.data.content.ContentIntegrityException
import net.opcapp.flash.data.content.HexagramContentRepository
import net.opcapp.flash.data.history.HistoryRepository import net.opcapp.flash.data.history.HistoryRepository
import net.opcapp.flash.data.history.SavedCastingSession import net.opcapp.flash.data.history.SavedCastingSession
import net.opcapp.flash.data.settings.HistorySettingsRepository import net.opcapp.flash.data.settings.HistorySettingsRepository
@@ -22,6 +25,7 @@ import net.opcapp.flash.domain.casting.CastMetadata
import net.opcapp.flash.domain.casting.CastResult import net.opcapp.flash.domain.casting.CastResult
import net.opcapp.flash.domain.casting.CastRound import net.opcapp.flash.domain.casting.CastRound
import net.opcapp.flash.domain.casting.CoinSide import net.opcapp.flash.domain.casting.CoinSide
import net.opcapp.flash.domain.casting.LineValue
sealed interface HistorySaveStatus { sealed interface HistorySaveStatus {
data object Draft : HistorySaveStatus data object Draft : HistorySaveStatus
@@ -52,6 +56,10 @@ data class CastingUiState(
val result: CastResult? = null, val result: CastResult? = null,
val sessionId: String? = null, val sessionId: String? = null,
val saveStatus: HistorySaveStatus = HistorySaveStatus.Draft, val saveStatus: HistorySaveStatus = HistorySaveStatus.Draft,
val primaryContent: HexagramContent? = null,
val transformedContent: HexagramContent? = null,
val contentError: String? = null,
val showSpecialUsage: Boolean = false,
) { ) {
val currentRoundNumber: Int val currentRoundNumber: Int
get() = (confirmedRounds.size + 1).coerceAtMost(6) get() = (confirmedRounds.size + 1).coerceAtMost(6)
@@ -67,12 +75,14 @@ class CastingViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val settingsRepository: HistorySettingsRepository, private val settingsRepository: HistorySettingsRepository,
private val contentRepository: HexagramContentRepository,
) : ViewModel() { ) : ViewModel() {
private val restoredRounds = decodeRounds(savedStateHandle[Keys.Rounds]) private val restoredRounds = decodeRounds(savedStateHandle[Keys.Rounds])
private val restoredCreatedAt = savedStateHandle.get<String>(Keys.CreatedAt) private val restoredCreatedAt = savedStateHandle.get<String>(Keys.CreatedAt)
private val restoredResult = restoredCreatedAt private val restoredResult = restoredCreatedAt
?.takeIf { restoredRounds.size == 6 } ?.takeIf { restoredRounds.size == 6 }
?.let { createdAt -> createResult(restoredRounds, createdAt) } ?.let { createdAt -> createResult(restoredRounds, createdAt) }
private val restoredContent = resolveContent(restoredResult)
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
CastingUiState( CastingUiState(
@@ -86,6 +96,10 @@ class CastingViewModel @Inject constructor(
} else { } else {
HistorySaveStatus.Saving HistorySaveStatus.Saving
}, },
primaryContent = restoredContent.primary,
transformedContent = restoredContent.transformed,
contentError = restoredContent.error,
showSpecialUsage = restoredContent.showSpecialUsage,
), ),
) )
val uiState: StateFlow<CastingUiState> = _uiState.asStateFlow() val uiState: StateFlow<CastingUiState> = _uiState.asStateFlow()
@@ -138,6 +152,7 @@ class CastingViewModel @Inject constructor(
val createdAt = Instant.now().toString() val createdAt = Instant.now().toString()
val sessionId = UUID.randomUUID().toString() val sessionId = UUID.randomUUID().toString()
val result = createResult(updatedRounds, createdAt) val result = createResult(updatedRounds, createdAt)
val content = resolveContent(result)
savedStateHandle[Keys.CreatedAt] = createdAt savedStateHandle[Keys.CreatedAt] = createdAt
savedStateHandle[Keys.SessionId] = sessionId savedStateHandle[Keys.SessionId] = sessionId
_uiState.value = state.copy( _uiState.value = state.copy(
@@ -146,6 +161,10 @@ class CastingViewModel @Inject constructor(
result = result, result = result,
sessionId = sessionId, sessionId = sessionId,
saveStatus = HistorySaveStatus.Saving, saveStatus = HistorySaveStatus.Saving,
primaryContent = content.primary,
transformedContent = content.transformed,
contentError = content.error,
showSpecialUsage = content.showSpecialUsage,
) )
persistResult(autoOnly = true) persistResult(autoOnly = true)
return true return true
@@ -288,11 +307,51 @@ class CastingViewModel @Inject constructor(
CastResult.record( CastResult.record(
computation = CastEngine.cast(rounds), computation = CastEngine.cast(rounds),
metadata = CastMetadata( metadata = CastMetadata(
contentVersion = CONTENT_VERSION, contentVersion = contentRepository.contentVersion,
createdAt = createdAt, createdAt = createdAt,
), ),
) )
private fun resolveContent(result: CastResult?): ResolvedContent {
if (result == null) return ResolvedContent()
val showSpecialUsage = when (result.primaryHexagramId.value) {
1 -> result.lineValuesBottomUp.all { it == LineValue.OLD_YANG }
2 -> result.lineValuesBottomUp.all { it == LineValue.OLD_YIN }
else -> false
}
return try {
val primary = contentRepository.contentFor(
result.primaryHexagramId.value,
result.contentVersion,
)
val transformed = if (result.transformedHexagramId == result.primaryHexagramId) {
primary
} else {
contentRepository.contentFor(
result.transformedHexagramId.value,
result.contentVersion,
)
}
ResolvedContent(
primary = primary,
transformed = transformed,
showSpecialUsage = showSpecialUsage,
)
} catch (error: ContentIntegrityException) {
ResolvedContent(
error = error.message,
showSpecialUsage = showSpecialUsage,
)
}
}
private data class ResolvedContent(
val primary: HexagramContent? = null,
val transformed: HexagramContent? = null,
val error: String? = null,
val showSpecialUsage: Boolean = false,
)
private fun decodeRounds(scores: ArrayList<Int>?): List<CastRound> = private fun decodeRounds(scores: ArrayList<Int>?): List<CastRound> =
scores.orEmpty().chunked(CastRound.COINS_PER_ROUND).map(CastRound::fromScores) scores.orEmpty().chunked(CastRound.COINS_PER_ROUND).map(CastRound::fromScores)
@@ -311,7 +370,4 @@ class CastingViewModel @Inject constructor(
const val SessionId = "casting_session_id" const val SessionId = "casting_session_id"
} }
companion object {
const val CONTENT_VERSION = "hexagram-names-v1"
}
} }
@@ -0,0 +1,121 @@
package net.opcapp.flash.feature.content
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import net.opcapp.flash.R
import net.opcapp.flash.core.model.ContentManifest
@Composable
fun ContentSourcesScreen(
manifest: ContentManifest,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = onBack)
Surface(
modifier = modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Box(contentAlignment = Alignment.TopCenter) {
Column(
modifier = Modifier
.widthIn(max = 600.dp)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TextButton(
onClick = onBack,
modifier = Modifier.height(48.dp),
) {
Text(stringResource(R.string.back))
}
Text(
text = stringResource(R.string.content_sources_title),
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = stringResource(R.string.content_sources_lede),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.content_version_label, manifest.contentVersion),
style = MaterialTheme.typography.titleLarge,
)
Text(
text = stringResource(R.string.content_digest_label, manifest.digest),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (manifest.loadError != null) {
Text(
text = stringResource(R.string.content_integrity_error),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
)
}
manifest.sources.forEach { source ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
shape = RoundedCornerShape(20.dp),
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = source.title, style = MaterialTheme.typography.titleLarge)
Text(
text = stringResource(R.string.content_source_edition, source.edition),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.content_source_license, source.license),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.content_source_url, source.url),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Text(
text = stringResource(R.string.content_review_pending),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -40,6 +40,7 @@ fun HomeScreen(
onStartCasting: () -> Unit, onStartCasting: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenMethodIntro: () -> Unit, onOpenMethodIntro: () -> Unit,
onOpenContentSources: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Surface( Surface(
@@ -134,6 +135,12 @@ fun HomeScreen(
) { ) {
Text(stringResource(R.string.open_method_intro)) Text(stringResource(R.string.open_method_intro))
} }
TextButton(
onClick = onOpenContentSources,
modifier = Modifier.height(48.dp),
) {
Text(stringResource(R.string.open_content_sources))
}
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.data.content.HexagramContentRepository
import net.opcapp.flash.data.history.HistoryRepository import net.opcapp.flash.data.history.HistoryRepository
import net.opcapp.flash.data.history.SavedSessionSummary import net.opcapp.flash.data.history.SavedSessionSummary
import net.opcapp.flash.data.settings.HistorySavePolicy import net.opcapp.flash.data.settings.HistorySavePolicy
@@ -20,13 +22,20 @@ data class HomeUiState(
val savePolicy: HistorySavePolicy = HistorySavePolicy(), val savePolicy: HistorySavePolicy = HistorySavePolicy(),
val hasSeenMethodIntro: Boolean = false, val hasSeenMethodIntro: Boolean = false,
val isReady: Boolean = false, val isReady: Boolean = false,
val contentManifest: ContentManifest = ContentManifest(
contentVersion = "unavailable",
digest = "unavailable",
sources = emptyList(),
),
) )
@HiltViewModel @HiltViewModel
class HomeViewModel @Inject constructor( class HomeViewModel @Inject constructor(
historyRepository: HistoryRepository, historyRepository: HistoryRepository,
private val settingsRepository: HistorySettingsRepository, private val settingsRepository: HistorySettingsRepository,
contentRepository: HexagramContentRepository,
) : ViewModel() { ) : ViewModel() {
private val contentManifest = contentRepository.manifest
val uiState: StateFlow<HomeUiState> = combine( val uiState: StateFlow<HomeUiState> = combine(
historyRepository.observeCount(), historyRepository.observeCount(),
historyRepository.observeLatest(), historyRepository.observeLatest(),
@@ -39,6 +48,7 @@ class HomeViewModel @Inject constructor(
savePolicy = savePolicy, savePolicy = savePolicy,
hasSeenMethodIntro = hasSeenMethodIntro, hasSeenMethodIntro = hasSeenMethodIntro,
isReady = true, isReady = true,
contentManifest = contentManifest,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -45,6 +45,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.opcapp.flash.R import net.opcapp.flash.R
import net.opcapp.flash.core.model.HexagramContent
import net.opcapp.flash.core.model.HexagramNames import net.opcapp.flash.core.model.HexagramNames
import net.opcapp.flash.domain.casting.CastResult import net.opcapp.flash.domain.casting.CastResult
import net.opcapp.flash.domain.casting.LineValue import net.opcapp.flash.domain.casting.LineValue
@@ -94,6 +95,10 @@ fun CastResultScreen(
ResultContent( ResultContent(
result = result, result = result,
saveStatus = uiState.saveStatus, saveStatus = uiState.saveStatus,
primaryContent = uiState.primaryContent,
transformedContent = uiState.transformedContent,
contentError = uiState.contentError,
showSpecialUsage = uiState.showSpecialUsage,
onSave = onSave, onSave = onSave,
onDelete = onDelete, onDelete = onDelete,
onRetry = onRetry, onRetry = onRetry,
@@ -110,6 +115,10 @@ fun CastResultScreen(
private fun ResultContent( private fun ResultContent(
result: CastResult, result: CastResult,
saveStatus: HistorySaveStatus, saveStatus: HistorySaveStatus,
primaryContent: HexagramContent?,
transformedContent: HexagramContent?,
contentError: String?,
showSpecialUsage: Boolean,
onSave: () -> Unit, onSave: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
@@ -175,6 +184,13 @@ private fun ResultContent(
} }
} }
RawLinesCard(result.lineValuesBottomUp) RawLinesCard(result.lineValuesBottomUp)
HexagramTextsCard(
result = result,
primaryContent = primaryContent,
transformedContent = transformedContent,
contentError = contentError,
showSpecialUsage = showSpecialUsage,
)
SaveStatusCard( SaveStatusCard(
status = saveStatus, status = saveStatus,
onSave = onSave, onSave = onSave,
@@ -182,7 +198,7 @@ private fun ResultContent(
onRetry = onRetry, onRetry = onRetry,
) )
Text( Text(
text = stringResource(R.string.content_package_pending), text = stringResource(R.string.content_plain_draft_notice),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -197,6 +213,104 @@ private fun ResultContent(
} }
} }
@Composable
private fun HexagramTextsCard(
result: CastResult,
primaryContent: HexagramContent?,
transformedContent: HexagramContent?,
contentError: String?,
showSpecialUsage: Boolean,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(20.dp),
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (contentError != null || primaryContent == null) {
Text(
text = stringResource(R.string.content_integrity_error),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
)
return@Column
}
LabeledPassage(
title = stringResource(R.string.primary_judgment_title),
original = primaryContent.judgmentOriginal,
plain = primaryContent.judgmentPlain,
)
if (result.movingLinePositions.isNotEmpty()) {
Text(
text = stringResource(R.string.moving_line_texts_title),
style = MaterialTheme.typography.titleLarge,
)
result.movingLinePositions.forEach { position ->
LabeledPassage(
title = linePositionName(position),
original = primaryContent.lineTextsBottomUp[position - 1],
plain = primaryContent.linePlainBottomUp[position - 1],
)
}
}
if (
transformedContent != null &&
result.transformedHexagramId != result.primaryHexagramId
) {
LabeledPassage(
title = stringResource(R.string.transformed_judgment_title),
original = transformedContent.judgmentOriginal,
plain = transformedContent.judgmentPlain,
)
}
if (showSpecialUsage && primaryContent.specialUsageText != null) {
LabeledPassage(
title = stringResource(R.string.special_usage_title),
original = primaryContent.specialUsageText,
plain = null,
)
}
}
}
}
@Composable
private fun LabeledPassage(
title: String,
original: String,
plain: String?,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
)
Text(
text = stringResource(R.string.content_original_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = original,
style = MaterialTheme.typography.bodyLarge,
)
if (plain != null) {
Text(
text = stringResource(R.string.content_plain_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = plain,
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
@Composable @Composable
private fun RawLinesCard(lines: List<LineValue>) { private fun RawLinesCard(lines: List<LineValue>) {
Card( Card(
+17 -1
View File
@@ -103,7 +103,23 @@
<string name="save_this_time">保存本次</string> <string name="save_this_time">保存本次</string>
<string name="do_not_save_this_time">本次不保存</string> <string name="do_not_save_this_time">本次不保存</string>
<string name="retry">重试</string> <string name="retry">重试</string>
<string name="content_package_pending">当前显示可复核的卦号、卦名和六爻事实。经典原文与现代白话将在来源和授权审核完成后接入。</string> <string name="content_original_label">原文</string>
<string name="content_plain_label">本地白话</string>
<string name="content_plain_draft_notice">白话为项目自撰草稿,供本机阅读;内容负责人审校前不视为可再分发。</string>
<string name="primary_judgment_title">本卦卦辞</string>
<string name="transformed_judgment_title">之卦卦辞</string>
<string name="moving_line_texts_title">动爻爻辞</string>
<string name="special_usage_title">用九 / 用六</string>
<string name="content_integrity_error">本地内容包未能通过完整性检查,卦辞暂不可读。卦号、卦名和六爻事实仍可复核。</string>
<string name="open_content_sources">内容来源</string>
<string name="content_sources_title">内容来源</string>
<string name="content_sources_lede">应用只使用已写入安装包的本地文本。原文来自可核验公版《易经》;白话由本项目撰写,审校完成前标为草稿。</string>
<string name="content_version_label">内容版本:%1$s</string>
<string name="content_digest_label">校验摘要:%1$s</string>
<string name="content_source_license">许可:%1$s</string>
<string name="content_source_edition">版本:%1$s</string>
<string name="content_source_url">来源:%1$s</string>
<string name="content_review_pending">内容负责人尚未确认本包可再分发;发布前须完成审校。</string>
<string name="start_again">再起一卦</string> <string name="start_again">再起一卦</string>
<string name="result_missing">结果状态已丢失,请返回首页重新开始。</string> <string name="result_missing">结果状态已丢失,请返回首页重新开始。</string>
<string name="delete_current_record_title">删除本次本机记录?</string> <string name="delete_current_record_title">删除本次本机记录?</string>
@@ -1,13 +1,23 @@
package net.opcapp.flash.data.content package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.core.model.ContentSource
import net.opcapp.flash.core.model.HexagramContent import net.opcapp.flash.core.model.HexagramContent
class FakeHexagramContentRepository( class FakeHexagramContentRepository(
override val contentVersion: String = "fixture-only-v1", override val contentVersion: String = "fixture-only-v1",
entries: List<HexagramContent>, entries: List<HexagramContent>,
sources: List<ContentSource> = emptyList(),
digest: String = "fixture-digest",
) : HexagramContentRepository { ) : HexagramContentRepository {
private val entriesById: Map<Int, HexagramContent> private val entriesById: Map<Int, HexagramContent>
override val manifest: ContentManifest = ContentManifest(
contentVersion = contentVersion,
digest = digest,
sources = sources,
)
init { init {
require(contentVersion.isNotBlank()) { "Content version must not be blank" } require(contentVersion.isNotBlank()) { "Content version must not be blank" }
require(entries.map(HexagramContent::kingWenNumber).distinct().size == entries.size) { require(entries.map(HexagramContent::kingWenNumber).distinct().size == entries.size) {
@@ -0,0 +1,39 @@
package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.HexagramNames
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class HexagramContentParserTest {
@Test
fun publishedPackageMatchesKingWenNamesAndSpecialUsage() {
val json = checkNotNull(javaClass.getResource("/hexagram-content.json")).readText()
val pack = HexagramContentParser.parse(json)
assertEquals("zh-Hans-2026.1", pack.manifest.contentVersion)
assertEquals(64, pack.hexagrams.size)
assertTrue(pack.manifest.digest.matches(Regex("[a-f0-9]{64}")))
assertEquals("复", pack.hexagrams.getValue(24).name)
assertEquals(HexagramNames.nameFor(24), pack.hexagrams.getValue(24).name)
assertEquals("䷗", pack.hexagrams.getValue(24).symbol)
assertTrue(pack.hexagrams.getValue(1).specialUsageText.orEmpty().contains("用九"))
assertTrue(pack.hexagrams.getValue(2).specialUsageText.orEmpty().contains("用六"))
assertNotNull(pack.manifest.sources.singleOrNull { it.id.contains("wikisource") })
assertNotNull(pack.manifest.sources.singleOrNull { it.id.contains("lingji-plain") })
}
@Test
fun parserRejectsWrongKingWenName() {
val json = checkNotNull(javaClass.getResource("/hexagram-content.json")).readText()
.replace("\"name\": \"复\"", "\"name\": \"錯\"")
try {
HexagramContentParser.parse(json)
fail("Expected content integrity failure")
} catch (error: ContentIntegrityException) {
assertTrue(error.message.orEmpty().contains("name"))
}
}
}
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
/** 灵机自撰白话草稿。按文王序 1–64;每项为 [卦辞, 初, 二, 三, 四, 五, 上]。 */
export const lingjiPlain = [
["开始即通达,利于守持正道。", "龙还潜藏着,先不要施展。", "龙出现在田野,利于去见有识之人。", "君子整日健进,夜里仍警惕;虽有危难,可以无灾。", "或跳进深渊,可以无灾。", "龙飞在天上,利于去见有识之人。", "龙飞得过高,会有悔恨。"],
["开始即通达,利于像母马那样守持正道。君子有所前往,先会迷路,随后才得到依靠。利于西南得到同类,东北失去同类。安于守正,吉。", "踩到霜,坚冰就要来了。", "正直方正而广大,不必演习,没有不利。", "蕴含文采,可以守正。或跟从王事,不求自己成功,却能有结果。", "扎紧口袋,无灾也无称誉。", "黄色下裳,大为吉祥。", "龙在郊野争斗,血流成青黄色。"],
["开始即通达,利于守正。不要急着前往,利于立诸侯。", "徘徊不进,利于安居守正,利于立诸侯。", "困顿难行,乘马盘旋。不是强盗,是来求婚。女子守正不嫁,十年才许嫁。", "追鹿却没有虞人向导,只是陷入林中。君子察觉危险,不如放弃;再往前会有憾恨。", "乘马盘旋,去求婚,前往吉,没有不利。", "屯积膏泽。小事守正吉,大事守正凶。", "乘马盘旋,血泪涟涟。"],
["亨通。我不是要去求童蒙,是童蒙来求我。初次卜问则告知;再三乱问,就不告知。利于守正。", "启发蒙昧,利于用刑罚纠正人,用桎梏约束;若一再用刑,就有憾恨。", "包容蒙昧,吉。接纳妇人,吉。孩子能自立,家庭就不再受困。", "不要娶这个女子,她见了财礼就失了自身。无利可图。", "困于蒙昧,憾恨。", "童蒙,吉。", "打击蒙昧。不利于做强盗,利于抵御强盗。"],
["有所等待,有诚信。光辉亨通,守正吉。利于涉大河。", "在郊野等待,利于用恒常的方法,无灾。", "在沙滩等待,小有议论,最终吉。", "在泥中等待,招来贼寇。", "在血中等待,从洞穴里出来。", "在酒食中等待,守正吉。", "进入洞穴,有不请自来的三位客人;尊敬他们,最终吉。"],
["有诚信,在窒塞中警惕,中正则吉,最终凶。利于见大人,不利于涉大河。", "不永久纠缠事务,小有议论,最终吉。", "败于讼事,回家逃避。邑人三百户,无灾。", "食用旧德,守正有危,最终吉。或跟从王事,没有成功。", "不能胜诉,仍复归于常理,改变态度,安静守正则吉。", "讼事,元吉。", "或赐给大带,一天之内被剥夺三次。"],
["贞正。大人吉,无灾。兵众,正而不邪才可用。", "出兵要有纪律;否则,凶。", "在队伍中,无灾,王三次赐命。", "师或载尸而还,凶。", "师左次而退,无灾。", "田里有禽,利于执言,无灾。长子帅师,弟子载尸,贞凶。", "大君有命,开国承家。小人不可重用。"],
["吉。原筮,元永贞,无灾。不安宁的人前来,后夫凶。", "有诚信,比之无灾。有诚信充满瓦缶,终来有别的吉。", "比之从内,贞吉。", "比之匪人。", "在外相比,贞吉。", "显比。王用三驱,失前禽。邑人不诫,吉。", "比之无首,凶。"],
["亨通。密云不雨,自我西郊。", "复归于自己的道路,何其咎?吉。", "牵复,吉。", "舆说辐。夫妻反目。", "有孚,血去惕出,无灾。", "有孚挛如,富以其邻。", "既雨既处,尚德载。妇贞厉。月几望,君子征凶。"],
["踩踏老虎尾巴,不咬人,亨通。", "素履往,无灾。", "履道坦坦,幽人贞吉。", "眇能视,跛能履,履虎尾,咥人,凶。武人为于大君。", "履虎尾,愬愬,终吉。", "夬履,贞厉。", "视履考祥,其旋元吉。"],
["小往大来,吉,亨通。", "拔茅连根,以其类,征吉。", "包荒,用冯河,不遐遗。朋亡,得尚于中行。", "无平不陂,无往不复。艰贞无灾。勿恤其孚,于食有福。", "翩翩,不富以其邻,不戒以孚。", "帝乙归妹,以祉元吉。", "城复于隍,勿用师。自邑告命,贞吝。"],
["否之匪人,不利于君子守正。大的往,小的来。", "拔茅连根,以其类,守正吉,亨通。", "包容奉承。小人吉,大人否塞。亨通。", "包藏羞辱。", "有天命,无灾。同类依附而得福。", "休止否塞,大人吉。心中念着将亡将亡,才能系于苞桑。", "倾覆否塞。先否塞,后喜悦。"],
["同人于野,亨通。利于涉大河,利于君子守正。", "同人于门,无灾。", "同人于宗,吝。", "伏戎于莽,升其高陵,三年不兴。", "乘其墉,弗克攻,吉。", "同人,先号咷而后笑。大师克相遇。", "同人于郊,无悔。"],
["元亨。", "无交害,匪咎,艰则无灾。", "大车以载,有攸往,无灾。", "公用亨于天子。小人弗克。", "匪其彭,无灾。", "厥孚交如,威如,吉。", "自天佑之,吉无不利。"],
["亨通。君子有终。", "谦谦君子,用涉大川,吉。", "鸣谦,贞吉。", "劳谦,君子有终,吉。", "无不利,撝谦。", "不富以其邻,利用侵伐,无不利。", "鸣谦,利用行师,征邑国。"],
["利于建侯行师。", "鸣豫,凶。", "介于石,不终日,贞吉。", "盱豫,悔。迟则有悔。", "由豫,大有得。勿疑,朋盍簪。", "贞疾,恒不死。", "冥豫,成有渝,无灾。"],
["元亨,利贞,无灾。", "官有渝,贞吉。出门交有功。", "系小子,失丈夫。", "系丈夫,失小子。随有求得,利居贞。", "随有获,贞凶。有孚在道,以明,何咎。", "孚于嘉,吉。", "拘系之,乃从维之。王用亨于西山。"],
["元亨,利涉大川。先甲三日,后甲三日。", "干父之蛊,有子,考无咎。厉,终吉。", "干母之蛊,不可贞。", "干父之蛊,小有悔,无大咎。", "裕父之蛊,往见吝。", "干父之蛊,用誉。", "不事王侯,高尚其事。"],
["元亨,利贞。至于八月有凶。", "咸临,贞吉。", "咸临,吉,无不利。", "甘临,无攸利。既忧之,无灾。", "至临,无灾。", "知临,大君之宜,吉。", "敦临,吉,无灾。"],
["盥而不荐,有孚颙若。", "童观,小人无咎,君子吝。", "窥观,利女贞。", "观我生,进退。", "观国之光,利用宾于王。", "观我生,君子无咎。", "观其生,君子无咎。"],
["亨通。利用狱。", "屦校灭趾,无灾。", "噬肤灭鼻,无灾。", "噬腊肉,遇毒。小吝,无灾。", "噬干胏,得金矢。利艰贞,吉。", "噬干肉,得黄金。贞厉,无灾。", "何校灭耳,凶。"],
["亨通。小利有攸往。", "贲其趾,舍车而徒。", "贲其须。", "贲如濡如,永贞吉。", "贲如皤如,白马翰如。匪寇婚媾。", "贲于丘园,束帛戋戋。吝,终吉。", "白贲,无灾。"],
["不利于有所前往。", "剥床以足,蔑贞,凶。", "剥床以辨,蔑贞,凶。", "剥之,无灾。", "剥床以肤,凶。", "贯鱼,以宫人宠,无不利。", "硕果不食。君子得舆,小人剥庐。"],
["亨通。出入没有疾病,朋友来也无灾。一来一回按那条路走,七天会回复。利于有所前往。", "回复得还不远,没有大的悔恨,大为吉祥。", "美好地回复,吉。", "频繁回复,有危难,无灾。", "走在行列中间,独自回复。", "敦厚地回复,没有悔恨。", "迷路却不回复,凶,有灾祸。用此出兵,终有大败,连累国君,凶;到十年还不能出征。"],
["元亨,利贞。其匪正有眚,不利有攸往。", "无妄,往吉。", "不耕获,不菑畲,则利有攸往。", "无妄之灾。或系之牛,行人之得,邑人之灾。", "可贞,无灾。", "无妄之疾,勿药有喜。", "无妄,行有眚,无攸利。"],
["利贞。不家食,吉。利涉大川。", "有厉,利已。", "舆说輹。", "良马逐,利艰贞。日闲舆卫,利有攸往。", "童牛之牿,元吉。", "豮豕之牙,吉。", "何天之衢,亨。"],
["贞吉。观颐,自求口实。", "舍尔灵龟,观我朵颐,凶。", "颠颐,拂经,于丘颐,征凶。", "拂颐,贞凶。十年勿用,无攸利。", "颠颐,吉。虎视眈眈,其欲逐逐,无灾。", "拂经,居贞吉,不可涉大川。", "由颐,厉吉,利涉大川。"],
["栋桡。利有攸往,亨。", "藉用白茅,无灾。", "枯杨生稊,老夫得其女妻,无不利。", "栋桡,凶。", "栋隆,吉。有它吝。", "枯杨生华,老妇得其士夫,无咎无誉。", "过涉灭顶,凶,无灾。"],
["有孚,维心亨,行有尚。", "习坎,入于坎窞,凶。", "坎有险,求小得。", "来之坎坎,险且枕,入于坎窞,勿用。", "樽酒簋贰,用缶,纳约自牖,终无灾。", "坎不盈,祗既平,无灾。", "系用徽纆,置于丛棘,三岁不得,凶。"],
["利贞,亨。畜牝牛,吉。", "履错然,敬之,无灾。", "黄离,元吉。", "日昃之离,不鼓缶而歌,则大耋之嗟,凶。", "突如其来如,焚如,死如,弃如。", "出涕沱若,戚嗟若,吉。", "王用出征,有嘉折首,获匪其丑,无灾。"],
["亨通。利贞。取女吉。", "咸其拇。", "咸其腓,凶。居吉。", "咸其股,执其随,往吝。", "贞吉,悔亡。憧憧往来,朋从尔思。", "咸其脢,无悔。", "咸其辅颊舌。"],
["亨通。无灾。利于守正。利于有所前往。", "深求恒久,守正则凶,无所利。", "悔恨消失。", "不能恒久其德,或许会承受羞辱,守正有憾。", "田里没有禽兽。", "恒久其德,守正。妇人吉,男子凶。", "振动不安于恒,凶。"],
["亨通。小利于守正。", "遁走只剩尾巴,有危,不要有所前往。", "用黄牛皮捆住,没有人能解脱。", "被牵绊着退避,有疾病和危难。畜养臣妾,吉。", "喜好退避,君子吉,小人否塞。", "嘉美地退避,守正吉。", "充裕地退避,没有不利。"],
["利于守正。", "壮在脚趾,出征凶,有诚信。", "贞吉。", "小人用壮,君子用罔,贞厉。公羊触藩,缠住了角。", "贞吉,悔恨消失。藩篱没被破坏,壮在大车的辐上。", "在田里丧失羊,无悔。", "公羊触藩,不能退不能进,无攸利。艰难则吉。"],
["康侯用锡马蕃庶,白天三次接见。", "晋如摧如,贞吉。罔孚,裕无灾。", "晋如愁如,贞吉。受兹介福,于其王母。", "众允,悔亡。", "晋如鼫鼠,贞厉。", "悔亡,失得勿恤。往吉,无不利。", "晋其角,维用伐邑,厉吉,无灾,贞吝。"],
["利于艰难中守正。", "明夷于飞,垂其翼。君子于行,三日不食。有攸往,主人有言。", "明夷,夷于左股,用拯马壮,吉。", "明夷于南狩,得其大首,不可疾贞。", "入于左腹,获明夷之心,于出门庭。", "箕子之明夷,利贞。", "不明晦。初登于天,后入于地。"],
["利于女子守正。", "闲有家,悔亡。", "无攸遂,在中馈,贞吉。", "家人嗃嗃,悔厉吉;妇子嘻嘻,终吝。", "富家,大吉。", "王假有家,勿恤,吉。", "有孚威如,终吉。"],
["小事吉。", "悔亡。丧马勿逐,自复。见恶人,无灾。", "遇主于巷,无灾。", "见舆曳,其牛掣,其人天且劓。无初有终。", "睽孤,遇元夫,交孚,厉无灾。", "悔亡。厥宗噬肤,往何咎。", "睽孤,见豕负涂,载鬼一车。先张之弧,后说之弧。匪寇婚媾。往遇雨则吉。"],
["利西南,不利东北。利见大人。贞吉。", "往蹇,来誉。", "王臣蹇蹇,匪躬之故。", "往蹇,来反。", "往蹇,来连。", "大蹇,朋来。", "往蹇,来硕,吉。利见大人。"],
["利西南。无所往,其来复吉。有攸往,夙吉。", "无灾。", "田获三狐,得黄矢,贞吉。", "负且乘,致寇至,贞吝。", "解而拇,朋至斯孚。", "君子维有解,吉。有孚于小人。", "公用射隼于高墉之上,获之,无不利。"],
["有孚,元吉,无灾,可贞,利有攸往。曷之用?二簋可用享。", "已事遄往,无灾。酌损之。", "利贞。征凶。弗损,益之。", "三人行则损一人,一人行则得其友。", "损其疾,使遄有喜,无灾。", "或益之十朋之龟,弗克违,元吉。", "弗损,益之,无灾。贞吉。利有攸往,得臣无家。"],
["利有攸往。利涉大川。", "利用为大作,元吉,无灾。", "或益之十朋之龟,弗克违。永贞吉。王用享于帝,吉。", "益之用凶事,无灾。有孚中行,告公用圭。", "中行,告公从,利用为依迁国。", "有孚惠心,勿问元吉。有孚惠我德。", "莫益之,或击之,立心勿恒,凶。"],
["扬于王庭。孚号有厉。告自邑,不利即戎,利有攸往。", "壮于前趾,往不胜为咎。", "惕号,莫夜有戎,勿恤。", "壮于頄,有凶。君子夬夬,独行遇雨,若濡有愠,无灾。", "臀无肤,其行次且。牵羊悔亡,闻言不信。", "苋陆夬夬,中行无灾。", "无号,终有凶。"],
["女壮,勿用取女。", "系于金柅,贞吉。有攸往,见凶。羸豕孚蹢躅。", "包有鱼,无灾,不利宾。", "臀无肤,其行次且,厉,无大咎。", "包无鱼,起凶。", "以杞包瓜,含章,有陨自天。", "姤其角,吝,无灾。"],
["亨通。王假有庙。利见大人,亨,利贞。用大牲吉。利有攸往。", "有孚不终,乃乱乃萃。若号,一握为笑,勿恤,往无灾。", "引吉,无灾。孚乃利用禴。", "萃如嗟如,无攸利。往无灾,小吝。", "大吉,无灾。", "萃有位,无灾。匪孚,元永贞,悔亡。", "赍咨涕洟,无灾。"],
["元亨。用见大人,勿恤。南征吉。", "允升,大吉。", "孚乃利用禴,无灾。", "升虚邑。", "王用亨于岐山,吉,无灾。", "贞吉,升阶。", "冥升,利于不息之贞。"],
["亨通。贞,大人吉,无灾。有言不信。", "臀困于株木,入于幽谷,三岁不觌。", "困于酒食,朱绂方来,利用享祀。征凶,无灾。", "困于石,据于蒺藜。入于其宫,不见其妻,凶。", "来徐徐,困于金车,吝,有终。", "劓刖,困于赤绂。乃徐有说,利用祭祀。", "困于葛藟,于臲卼。曰动悔有悔,征吉。"],
["改邑不改井,无丧无得。往来井井。汔至,亦未繘井,羸其瓶,凶。", "井泥不食,旧井无禽。", "井谷射鲋,瓮敝漏。", "井渫不食,为我心恻。可用汲,王明,并受其福。", "井甃,无灾。", "井冽,寒泉食。", "井收勿幕,有孚元吉。"],
["己日乃孚。元亨,利贞,悔亡。", "巩用黄牛之革。", "己日乃革之,征吉,无灾。", "征凶,贞厉。革言三就,有孚。", "悔亡。有孚改命,吉。", "大人虎变,未占有孚。", "君子豹变,小人革面。征凶,居贞吉。"],
["元吉,亨通。", "鼎颠趾,利出否。得妾以其子,无灾。", "鼎有实。我仇有疾,不我能即,吉。", "鼎耳革,其行塞,雉膏不食。方雨亏悔,终吉。", "鼎折足,覆公餗,其形渥,凶。", "鼎黄耳金铉,利贞。", "鼎玉铉,大吉,无不利。"],
["亨通。震来虩虩,笑言哑哑。震惊百里,不丧匕鬯。", "震来虩虩,后笑言哑哑,吉。", "震来厉,亿丧贝。跻于九陵,勿逐,七日得。", "震苏苏,震行无眚。", "震遂泥。", "震往来厉,亿无丧,有事。", "震索索,视矍矍,征凶。震不于其躬,于其邻,无灾。婚媾有言。"],
["艮其背,不获其身;行其庭,不见其人,无灾。", "艮其趾,无灾,利永贞。", "艮其腓,不拯其随,其心不快。", "艮其限,列其夤,厉薰心。", "艮其身,无灾。", "艮其辅,言有序,悔亡。", "敦艮,吉。"],
["女归吉。利贞。", "鸿渐于干,小子厉,有言,无灾。", "鸿渐于磐,饮食衎衎,吉。", "鸿渐于陆,夫征不复,妇孕不育,凶。利御寇。", "鸿渐于木,或得其桷,无灾。", "鸿渐于陵,妇三岁不孕,终莫之胜,吉。", "鸿渐于陆,其羽可用为仪,吉。"],
["征凶,无攸利。", "归妹以娣,跛能履,征吉。", "眇能视,利幽人之贞。", "归妹以须,反归以娣。", "归妹愆期,迟归有时。", "帝乙归妹,其君之袂不如其娣之袂良。月几望,吉。", "女承筐无实,士刲羊无血,无攸利。"],
["亨通。王假之。勿忧,宜日中。", "遇其配主,虽旬无灾,往有尚。", "丰其蔀,日中见斗。往得疑疾,有孚发若,吉。", "丰其沛,日中见沫。折其右肱,无灾。", "丰其蔀,日中见斗。遇其夷主,吉。", "来章,有庆誉,吉。", "丰其屋,蔀其家,窥其户,阒其无人,三岁不觌,凶。"],
["小亨。旅贞吉。", "旅琐琐,斯其所取灾。", "旅即次,怀其资,得童仆贞。", "旅焚其次,丧其童仆,贞厉。", "旅于处,得其资斧,我心不快。", "射雉,一矢亡,终以誉命。", "鸟焚其巢,旅人先笑后号咷。丧牛于易,凶。"],
["小亨。利有攸往。利见大人。", "进退,利武人之贞。", "巽在床下,用史巫纷若,吉,无灾。", "频巽,吝。", "悔亡,田获三品。", "贞吉,悔亡,无不利。无初有终。先庚三日,后庚三日,吉。", "巽在床下,丧其资斧,贞凶。"],
["亨通。利贞。", "和兑,吉。", "孚兑,吉,悔亡。", "来兑,凶。", "商兑未宁,介疾有喜。", "孚于剥,有厉。", "引兑。"],
["亨通。王假有庙。利涉大川,利贞。", "用拯马壮,吉。", "涣奔其机,悔亡。", "涣其躬,无悔。", "涣其群,元吉。涣有丘,匪夷所思。", "涣汗其大号,涣王居,无灾。", "涣其血,去逖出,无灾。"],
["亨通。苦节不可贞。", "不出户庭,无灾。", "不出门庭,凶。", "不节若,则嗟若,无灾。", "安节,亨。", "甘节,吉。往有尚。", "苦节,贞凶,悔亡。"],
["豚鱼吉。利涉大川,利贞。", "虞吉,有它不燕。", "鸣鹤在阴,其子和之。我有好爵,吾与尔靡之。", "得敌,或鼓或罢,或泣或歌。", "月几望,马匹亡,无灾。", "有孚挛如,无灾。", "翰音登于天,贞凶。"],
["亨通。利贞。可小事,不可大事。飞鸟遗之音,不宜上,宜下,大吉。", "飞鸟以凶。", "过其祖,遇其妣。不及其君,遇其臣,无灾。", "弗过防之,从或戕之,凶。", "无灾。弗过遇之。往厉必戒,勿用永贞。", "密云不雨,自我西郊。公弋取彼在穴。", "弗遇过之,飞鸟离之,凶。是谓灾眚。"],
["亨通。小利贞。初吉终乱。", "曳其轮,濡其尾,无灾。", "妇丧其茀,勿逐,七日得。", "高宗伐鬼方,三年克之,小人勿用。", "繻有衣袽,终日戒。", "东邻杀牛,不如西邻之禴祭,实受其福。", "濡其首,厉。"],
["亨通。小狐汔济,濡其尾,无攸利。", "濡其尾,吝。", "曳其轮,贞吉。", "未济,征凶,利涉大川。", "贞吉,悔亡。震用伐鬼方,三年有赏于大国。", "贞吉,无悔。君子之光,有孚,吉。", "有孚于饮酒,无灾。濡其首,有孚失是。"],
];
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -2,7 +2,7 @@
> 文档状态:方案基线 > 文档状态:方案基线
> 最后核验:2026-08-19 > 最后核验:2026-08-19
> 当前阶段:正式产品名“灵机”;P0/P1 已完成;P3 离线主流程(方法说明、起念、手录、卦名/动爻结果)已可走通;P4 仅完成会话级本机保存与设置,问卦簿与本地解释未开始;P2 授权内容未开始;P-1 v0.3 待视觉复核;P5 未开始 > 当前阶段:正式产品名“灵机”;P0/P1 已完成;P3 离线主流程(方法说明、起念、手录、卦名/动爻结果)已可走通;P4 仅完成会话级本机保存与设置,问卦簿与本地解释未开始;P2 已接入维基文库《易经》原文与项目自撰白话草稿,待内容负责人审校;P-1 v0.3 待视觉复核;P5 未开始
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。 本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
@@ -22,6 +22,7 @@
| [移动端交互原型](prototype.md) | 评审流程、视觉或开始 Compose 页面前 | 可点击原型、截图入口、确认清单和 Android 映射 | | [移动端交互原型](prototype.md) | 评审流程、视觉或开始 Compose 页面前 | 可点击原型、截图入口、确认清单和 Android 映射 |
| [系统架构](architecture.md) | 新增包、依赖、数据源或网络能力时 | 分层、依赖方向、运行时数据流 | | [系统架构](architecture.md) | 新增包、依赖、数据源或网络能力时 | 分层、依赖方向、运行时数据流 |
| [数据与内容](data-content.md) | 修改卦库、历史记录或内容来源时 | 数据契约、授权、隐私和迁移规则 | | [数据与内容](data-content.md) | 修改卦库、历史记录或内容来源时 | 数据契约、授权、隐私和迁移规则 |
| [内容审校](content-review.md) | 审校卦辞来源或准备发布时 | 来源、异文和签核状态 |
| [AI 解释与安全](ai-safety.md) | 修改提示词、模型调用或解释结果时 | AI 调用门、输入输出契约和安全边界 | | [AI 解释与安全](ai-safety.md) | 修改提示词、模型调用或解释结果时 | AI 调用门、输入输出契约和安全边界 |
| [质量门禁](quality-gates.md) | 实现、评审、发布前 | 自动化验证、需求追踪和完成定义 | | [质量门禁](quality-gates.md) | 实现、评审、发布前 | 自动化验证、需求追踪和完成定义 |
| [依赖与许可证](dependency-licenses.md) | 新增/升级依赖或准备分发时 | 当前解析依赖、SPDX 与权威许可来源 | | [依赖与许可证](dependency-licenses.md) | 新增/升级依赖或准备分发时 | 当前解析依赖、SPDX 与权威许可来源 |
+1 -1
View File
@@ -69,7 +69,7 @@ app/src/main/java/net/opcapp/flash/
测试按相同包结构镜像放入 `src/test` 和 `src/androidTest`。 测试按相同包结构镜像放入 `src/test` 和 `src/androidTest`。
当前已落地的包:`app/`、`core/model`、`core/designsystem`、`domain/casting`、`data/content`(接口与测试 fake)、`data/history`、`data/settings`、`feature/onboarding`、`feature/home`、`feature/question`、`feature/casting`、`feature/result`、`feature/settings`。尚未创建 `feature/history`、`feature/explanation`、`domain/explanation` 和 `data/ai`。首页不为问卦簿或「解」生成空占位。 当前已落地的包:`app/`、`core/model`、`core/designsystem`、`domain/casting`、`data/content`(assets 解析器与测试 fake)、`data/history`、`data/settings`、`feature/onboarding`、`feature/home`、`feature/question`、`feature/casting`、`feature/result`、`feature/settings`、`feature/content`。尚未创建 `feature/history`、`feature/explanation`、`domain/explanation` 和 `data/ai`。首页不为问卦簿或「解」生成空占位。
## 4. 依赖方向 ## 4. 依赖方向
+34
View File
@@ -0,0 +1,34 @@
# 内容审校记录
> 状态:P2 数据包已生成,待内容负责人确认可再分发
> 内容版本:`zh-Hans-2026.1`
## 已接受来源
| 来源 ID | 文本 | 许可 | 说明 |
|---|---|---|---|
| `wikisource-zhouyi-jing-zh-Hans` | 卦辞、爻辞、乾用九、坤用六 | 公版(Wikimedia PD-old) | 维基文库《周易》仅《易经》;不含彖、象、文言等十翼。导入脚本:`scripts/import-zhouyi-wikisource.mjs` |
| `lingji-plain-zh-Hans-2026.1` | 本地白话 | 项目自撰草稿 | `content/plain/lingji-plain.mjs`;审校完成前不得当作已授权现代译本 |
未采用南怀瑾、傅佩荣或其他仍受版权保护的现代译注。
## 工程入口
- 原始导入:`content/raw/zhouyi-wikisource-jing.json`
- 发布包:`content/packages/hexagram-content.json`
- 构建:`node scripts/build-hexagram-package.mjs`
- 再导入需本机 HTTP 代理(默认 `http://127.0.0.1:1080`)访问 `zh.wikisource.org`
## 已知异文
维基文库复卦初九作「不复远,无袛悔」,与常见通行本「不远复,无祇悔」不同。本包按维基文库底本收录,不在导入时改字。审校时可决定是否改从通行本并提升 `contentVersion`。
## 审校清单
- [ ] 64 卦卦辞、384 爻与通行《易经》对读,记录有意保留的异文
- [ ] 确认不含十翼
- [ ] 白话无占断口吻、无未授权现代译注抄袭
- [ ] 乾用九、坤用六原文无误
- [ ] 内容负责人签署可再分发
签署前,P2 不得标记完成,商店发布不得进行。
+2 -2
View File
@@ -1,6 +1,6 @@
# 数据、内容与隐私契约 # 数据、内容与隐私契约
> 状态:结构已定义,内容来源与授权仍为发布阻塞项 > 状态:`zh-Hans-2026.1` 已接入应用;白话为草稿,发布仍待审校
> 适用范围:卦库 assets、Room、DataStore、导入脚本和内容审核 > 适用范围:卦库 assets、Room、DataStore、导入脚本和内容审核
## 1. 数据分类 ## 1. 数据分类
@@ -58,7 +58,7 @@
示例中的省略号不是可发布内容。禁止由 AI 在构建时临时补齐缺失卦辞或爻辞。 示例中的省略号不是可发布内容。禁止由 AI 在构建时临时补齐缺失卦辞或爻辞。
机器契约位于 `content/schema/hexagram-content.schema.json`。`specialUsageTexts` 显式声明当前内容版本是否提供乾“用九”和坤“用六”;声明为 `false` 时对应条目的 `specialUsageText` 必须为 `null`,不能用空字符串暗示内容存在。Android parser 尚未建立前,`scripts/verify-content-contract.mjs` 已提供独立构建期校验、稳定 SHA-256 摘要和不含可发布卦辞的自动化夹具;`HexagramContentRepository` 接口与测试 fake 已建立,缺少 ID 或内容版本不匹配时抛出数据完整性错误,不回退到相邻条目。应用当前只使用文王卦名查找表展示结果,不把未授权卦辞打入 APK。 机器契约位于 `content/schema/hexagram-content.schema.json`。`specialUsageTexts` 显式声明当前内容版本是否提供乾“用九”和坤“用六”;声明为 `false` 时对应条目的 `specialUsageText` 必须为 `null`,不能用空字符串暗示内容存在。`scripts/verify-content-contract.mjs` 校验夹具与 `content/packages/hexagram-content.json`;Kotlin `HexagramContentParser` 用同一套规则加载 assets。`HexagramContentRepository` 在缺少 ID 或内容版本不匹配时抛出数据完整性错误,不回退到相邻条目。来源与审校状态见 [内容审校](content-review.md)。
## 3. 内容完整性门禁 ## 3. 内容完整性门禁
+24 -2
View File
@@ -142,14 +142,36 @@
- 后果:主代码迁移到 `net.opcapp.flash`;Android 壳使用“灵机”和 `minSdk=26`。应用图标、商店文案和签名仍未由本决策确认。当前 `compileSdk/targetSdk=34` 是已安装工具链基线,不代表永久商店目标;发布前须按当时商店要求复核升级。 - 后果:主代码迁移到 `net.opcapp.flash`;Android 壳使用“灵机”和 `minSdk=26`。应用图标、商店文案和签名仍未由本决策确认。当前 `compileSdk/targetSdk=34` 是已安装工具链基线,不代表永久商店目标;发布前须按当时商店要求复核升级。
- 复审触发:组织域名所有权变化、发布账号要求更换 ID,或依赖/覆盖率数据要求提高最低系统版本。application ID 一旦发布不得轻率更换。 - 复审触发:组织域名所有权变化、发布账号要求更换 ID,或依赖/覆盖率数据要求提高最低系统版本。application ID 一旦发布不得轻率更换。
## ADR-015:自有白话 + 维基文库公版《易经》原文
- 状态:`Accepted`
- 日期:2026-08-19
- 关联:TBD-005、FR-R-005、P2
- 决定:安装包内的经典原文取自维基文库《周易》的《易经》部分(卦辞、爻辞,以及乾用九、坤用六),不含十翼;现代白话由本项目自撰,标为草稿,待内容负责人审校后才视为可再分发。不把南怀瑾、傅佩荣等现代译注入 APK。
- 原因:需要可核验、可再分发的公版原文,同时避免把仍受版权保护的现代译文误当成古籍公版。
- 备选:等待商业授权会继续阻塞离线阅读;使用来源不明的网络译文无法通过内容门禁。
- 后果:原文事实源为 `content/raw/zhouyi-wikisource-jing.json`,经 `scripts/import-zhouyi-wikisource.mjs` 导入并由 Wikimedia `zh-hans` 转换;白话事实源为 `content/plain/lingji-plain.mjs`;发布包为 `content/packages/hexagram-content.json`,构建时拷入 assets。应用内必须区分“原文”与“本地白话”,并提供内容来源页。P2 在审校签核前不得标为完成。
- 复审条件:维基文库页面结构变化、发现与通行本有影响阅读的异文、或内容负责人改用另一公版底本。
## ADR-016:乾用九、坤用六在内容具备时展示
- 状态:`Accepted`
- 日期:2026-08-19
- 关联:TBD-006、P2/P3
- 决定:内容包声明并收录乾“用九”、坤“用六”。结果页仅在本卦为乾且六爻皆老阳,或本卦为坤且六爻皆老阴时展示对应特殊文本。
- 原因:用九/用六是《易经》文本的一部分,不是另造的占断;只有全动时才按传统用法出示,避免静卦或单爻动时误读。
- 备选:一律隐藏会丢掉已具备的原文;凡见乾坤都展示会把特殊用法当成普通爻辞。
- 后果:`specialUsageTexts.qian/kun` 必须为 true,且仅乾、坤可有 `specialUsageText`。之卦为乾坤但不满足全九/全六时不展示用九/用六。
- 复审条件:产品决定对之卦或非全动情形也提示该文本。
## 未决问题 ## 未决问题
| ID | 问题 | 推荐默认 | 阻塞阶段 | | ID | 问题 | 推荐默认 | 阻塞阶段 |
|---|---|---|---| |---|---|---|---|
| TBD-001 | 应用图标与商店素材 | 产品名已由 ADR-014 确认为“灵机”;图标不使用未确认成稿 | P6 商店配置 | | TBD-001 | 应用图标与商店素材 | 产品名已由 ADR-014 确认为“灵机”;图标不使用未确认成稿 | P6 商店配置 |
| TBD-004 | 问题是否允许留空 | 允许选择“不写具体内容”,但需显式操作 | P3 | | TBD-004 | 问题是否允许留空 | 允许选择“不写具体内容”,但需显式操作 | P3 |
| TBD-005 | 经典原文、现代白话的版本与授权 | 自有白话 + 可核验公版原文 | P2,发布阻塞 | | TBD-005 | 经典原文、现代白话的版本与授权 | 已由 ADR-015 接受;内容负责人审校仍阻塞发布 | P2,发布阻塞 |
| TBD-006 | 乾用九、坤用六是否纳入 MVP | 内容具备时展示 | P2/P3 | | TBD-006 | 乾用九、坤用六是否纳入 MVP | 已由 ADR-016 接受:全九/全六时展示 | P2/P3 |
| TBD-009 | AI 模型供应商与自有后端 | 供应商无关接口;先交付本地版 | P5 | | TBD-009 | AI 模型供应商与自有后端 | 供应商无关接口;先交付本地版 | P5 |
| TBD-010 | 服务端问题/回复保留期 | 最小化且明确披露,优先不持久化正文 | P5,发布阻塞 | | TBD-010 | 服务端问题/回复保留期 | 最小化且明确披露,优先不持久化正文 | P5,发布阻塞 |
| TBD-011 | 高风险本地资源表覆盖地区 | 首发市场确认后维护,不让模型编号码 | P5 | | TBD-011 | 高风险本地资源表覆盖地区 | 首发市场确认后维护,不让模型编号码 | P5 |
+1 -1
View File
@@ -41,4 +41,4 @@
| `kotlin-runtime-policy` | `org.jetbrains.kotlin` | 运行时传递依赖 | Apache-2.0 | [Kotlin license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) | | `kotlin-runtime-policy` | `org.jetbrains.kotlin` | 运行时传递依赖 | Apache-2.0 | [Kotlin license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
| `kotlinx-runtime-policy` | `org.jetbrains.kotlinx` | 运行时传递依赖 | Apache-2.0 | [Kotlinx Coroutines license](https://github.com/Kotlin/kotlinx.coroutines/blob/1.7.1/LICENSE.txt) | | `kotlinx-runtime-policy` | `org.jetbrains.kotlinx` | 运行时传递依赖 | Apache-2.0 | [Kotlinx Coroutines license](https://github.com/Kotlin/kotlinx.coroutines/blob/1.7.1/LICENSE.txt) |
当前没有第三方字体、纹理、插画、音效或可发布《易经》内容进入工程。应用仅使用 Android 系统字体和代码绘制的卦象;这句话只描述本提交时的资源图,不构成未来授权。 当前没有第三方字体、纹理、插画或音效进入工程。应用使用 Android 系统字体、代码绘制的卦象,以及 `content/packages/hexagram-content.json` 中的维基文库公版《易经》与项目自撰白话草稿;这句话只描述本提交时的资源图,不构成内容负责人已签核。
+1 -1
View File
@@ -157,7 +157,7 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
- `PATH` 中 ADB 1.0.32 与 SDK ADB 1.0.41 会争用 server;当前三星设备的可重复门禁需要显式选择便携 ADB,后续应评估统一驱动和 Platform Tools。 - `PATH` 中 ADB 1.0.32 与 SDK ADB 1.0.41 会争用 server;当前三星设备的可重复门禁需要显式选择便携 ADB,后续应评估统一驱动和 Platform Tools。
- 代理已配置;本轮 Maven 依赖下载成功,但不能据此保证未来网络始终可用。 - 代理已配置;本轮 Maven 依赖下载成功,但不能据此保证未来网络始终可用。
已解除的缺口:Gradle Wrapper、Version Catalog、根 `AGENTS.md`、CI、正式 Android 身份和 application 壳均已建立。2026-08-19,`verifyLocal` 通过(含 22 个 debug 单元测试、`lintDebug` 与 debug APK);便携 ADB 下 Samsung API 31 真机 `run-connected-tests.ps1` 得到 `OK (4 tests)`,覆盖方法说明、保存设置、夹具「复 24 → 坤 2、初爻动」本机保存/当次删除,以及 Room 会话读写。未发现应用崩溃日志。飞行模式人工走查、无障碍抽测和授权内容包仍未做。 已解除的缺口:Gradle Wrapper、Version Catalog、根 `AGENTS.md`、CI、正式 Android 身份和 application 壳均已建立。2026-08-19,`verifyLocal` 通过(含 debug 单元测试、`lintDebug` 与 debug APK,已校验 `zh-Hans-2026.1` 内容包);便携 ADB 下 Samsung API 31 真机 `run-connected-tests.ps1` 得到 `OK (4 tests)`,覆盖方法说明、保存设置、夹具「复 24 → 坤 2、初爻动」本机保存/当次删除(结果页已展示原文/白话),以及 Room 会话读写。未发现应用崩溃日志。飞行模式人工走查、无障碍抽测和内容负责人签核仍未做。
这些缺口分别由[实施计划](implementation-plan.md)的 P0 和[质量门禁](quality-gates.md)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。 这些缺口分别由[实施计划](implementation-plan.md)的 P0 和[质量门禁](quality-gates.md)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。
+11 -11
View File
@@ -1,6 +1,6 @@
# 分阶段实施计划 # 分阶段实施计划
> 状态:P0/P1 已完成;P3 离线主流程已落地但未达到 UX 全表验收;P4 会话保存已落地,问卦簿/解读未开始;P2 授权内容未开始;P-1 v0.3 待视觉复核 > 状态:P0/P1 已完成;P3 离线主流程已落地但未达到 UX 全表验收;P4 会话保存已落地,问卦簿/解读未开始;P2 已接入公版《易经》与自撰白话草稿,待审校签核;P-1 v0.3 待视觉复核
> 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI > 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI
## 1. 依赖图 ## 1. 依赖图
@@ -92,18 +92,18 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
任务: 任务:
- [ ] 决定原文版本、现代白话来源和授权(TBD-005,发布阻塞)。 - [x] 决定原文版本、现代白话来源和授权(TBD-005):ADR-015 接受“自有白话 + 维基文库公版《易经》原文”;发布仍待审校签核。
- [ ] 实现 JSON schema、解析器和内容版本:`schemaVersion=1` 的机器 schema 已完成;Android/Kotlin assets 解析器待 Android 壳建立。 - [x] 实现 JSON schema、解析器和内容版本:`schemaVersion=1` 的机器 schema、Kotlin assets 解析器与 `zh-Hans-2026.1` 包已落地。
- [ ] 录入/导入 64 卦、卦辞、384 条爻辞及所需特殊文本。 - [x] 录入/导入 64 卦、卦辞、384 条爻辞及乾用九、坤用六;白话为项目自撰草稿。
- [ ] 建立来源清单、许可证清单和内容审核记录;schema 已强制每个来源包含版本、许可证与 URL。 - [x] 建立来源清单、许可证清单和内容审核记录:见 [内容审校](content-review.md);schema 已强制每个来源包含版本、许可证与 URL。
- [x] 实现构建期完整性校验、文王序号/上下卦/bottom-up 交叉校验、稳定 SHA-256 摘要及 8 个负向夹具。 - [x] 实现构建期完整性校验、文王序号/上下卦/bottom-up 交叉校验、稳定 SHA-256 摘要及 8 个负向夹具。
- [ ] 实现 `HexagramContentRepository` fake 与 assets 版本:接口、强校验只读模型和测试 fake 已完成,assets 实现待 Android parser。 - [x] 实现 `HexagramContentRepository` fake 与 assets 版本:加载失败时展示完整性错误,不回退到相邻卦。
退出条件: 退出条件:
- 64 卦/384 爻数据完整、唯一且来源可追踪。 - [x] 64 卦/384 爻数据完整、唯一且来源可追踪。
- 缺失、重复、非法顺序和错误映射测试均能失败。 - [x] 缺失、重复、非法顺序和错误映射测试均能失败。
- 内容负责人确认可再分发。 - [ ] 内容负责人确认可再分发。
## 6. P3:核心用户流程与东方设计系统 ## 6. P3:核心用户流程与东方设计系统
@@ -116,7 +116,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
- [x] 实现首次说明、起念、投币和结果页面:方法说明、问题、六轮手录和结果已接入导航;空问题仍被拒绝,TBD-004 未决。 - [x] 实现首次说明、起念、投币和结果页面:方法说明、问题、六轮手录和结果已接入导航;空问题仍被拒绝,TBD-004 未决。
- [x] 实现 `CastingViewModel` 状态机及 SavedState 恢复:问题、已确认爻和当前轮次写入 `SavedStateHandle`;第六轮才调用 `CastEngine`。 - [x] 实现 `CastingViewModel` 状态机及 SavedState 恢复:问题、已确认爻和当前轮次写入 `SavedStateHandle`;第六轮才调用 `CastEngine`。
- [x] 支持前五轮返回修改、第六轮封印和明确重新起卦。 - [x] 支持前五轮返回修改、第六轮封印和明确重新起卦。
- [ ] 接入本地内容并区分原文/本地白话:结果页只展示卦名、卦号、六爻事实,并明示授权内容包未接入。 - [x] 接入本地内容并区分原文/本地白话:结果页展示本卦卦辞、动爻爻辞和之卦卦辞,并标注原文/白话;乾全九、坤全六时展示用九/用六。白话仍为待审校草稿。
- [ ] 完成深浅主题、字体缩放、TalkBack、横屏和大屏适配:深浅色随系统;其余未做发布级验收。 - [ ] 完成深浅主题、字体缩放、TalkBack、横屏和大屏适配:深浅色随系统;其余未做发布级验收。
退出条件: 退出条件:
@@ -126,7 +126,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
- AI/网络代码尚未存在也不影响流程。 - AI/网络代码尚未存在也不影响流程。
- [UX 与东方视觉](ux-design.md)检查表通过。 - [UX 与东方视觉](ux-design.md)检查表通过。
当前证据(2026-08-19):`verifyLocal` 通过;仪器测试覆盖方法说明、保存设置和夹具「复 24 → 坤 2、初爻动」的本机保存/当次删除。P3 仍未退出,因为授权卦辞、「解」、问卦簿入口和 UX 全表未完成。飞行模式未做人工走查。 当前证据(2026-08-19):`verifyLocal` 通过;仪器测试覆盖方法说明、保存设置和夹具「复 24 → 坤 2、初爻动」的本机保存/当次删除。P3 仍未退出,因为「解」、问卦簿入口和 UX 全表未完成。飞行模式未做人工走查。内容包已接入但白话待审校。
## 7. P4:本地解释与历史 ## 7. P4:本地解释与历史
+1 -1
View File
@@ -128,7 +128,7 @@
| FR-C-007 | SavedState/重建测试 | 旋转、切后台、进程恢复 | | FR-C-007 | SavedState/重建测试 | 旋转、切后台、进程恢复 |
| FR-R-001~003 | UI 语义与 screenshot 测试 | TalkBack、色觉与长文阅读 | | FR-R-001~003 | UI 语义与 screenshot 测试 | TalkBack、色觉与长文阅读 |
| FR-R-004 | 网络失败测试 | 飞行模式 | | FR-R-004 | 网络失败测试 | 飞行模式 |
| FR-R-005 | 内容 schema/授权清单检查 | 内容负责人签核 | | FR-R-005 | 内容 schema/授权清单检查 | 自动校验已接入;内容负责人签核仍待完成 |
| FR-E-001~003 | 网络调用次数与同意状态测试 | 首次同意流程 | | FR-E-001~003 | 网络调用次数与同意状态测试 | 首次同意流程 |
| FR-E-004~006 | 输出 schema + 安全用例集 | 安全/产品审核 | | FR-E-004~006 | 输出 schema + 安全用例集 | 安全/产品审核 |
| FR-H-001~008 | 保存策略、Room 事务/版本、删除、备份排除、零网络调用测试 | 首页告知、设置、当次退出与删除体验 | | FR-H-001~008 | 保存策略、Room 事务/版本、删除、备份排除、零网络调用测试 | 首页告知、设置、当次退出与删除体验 |
+1 -1
View File
@@ -2,7 +2,7 @@
> 状态:MVP 设计基线 > 状态:MVP 设计基线
> 适用范围:Android 手机优先,兼顾横屏、平板和系统无障碍设置 > 适用范围:Android 手机优先,兼顾横屏、平板和系统无障碍设置
> 当前实现:Compose 已覆盖方法说明、回访首页、起念、六轮手录、卦名/动爻结果和保存设置;问卦簿列表、「解」与授权卦辞仍未进入应用,不能把本文件尚未实现的页面当成已上线功能 > 当前实现:Compose 已覆盖方法说明、回访首页、起念、六轮手录、卦名/动爻结果、原文/本地白话和保存设置;问卦簿列表与「解」仍未进入应用,不能把本文件尚未实现的页面当成已上线功能
## 1. 体验定位 ## 1. 体验定位
+85
View File
@@ -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`);
+221
View File
@@ -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(/&nbsp;/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(/&quot;/gu, "\"")
.replace(/&amp;/gu, "&")
.replace(/&lt;/gu, "<")
.replace(/&gt;/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`);
+49
View File
@@ -104,3 +104,52 @@ for (const [name, mutate, expected] of negativeCases) {
console.log( console.log(
`Content contract verification passed (64 entries cross-checked with Kotlin, ${negativeCases.length} negative fixtures, digest ${digest.slice(0, 12)}…).`, `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)}…).`,
);