Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Apps/AIMESettings/Views/LibraryViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ private struct SubscriptionsSection: View {
Button("订阅", action: add).buttonStyle(.borderedProminent).disabled(url.isEmpty || !model.canAddSubscription)
}
.controlSize(.small)
Text("支持 RIME dict.yaml、「词条⇥编码⇥权重」表格或每行一个词;拼音自动生成,全拼与小鹤双拼均可打出。")
Text("支持 RIME dict.yaml、「词条⇥编码⇥权重」表格或每行一个词;拼音自动生成,全拼、小鹤双拼与自然码双拼均可打出。")
.font(.caption).foregroundStyle(.secondary)
}
if let activity = model.subscriptionActivity {
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and

## [Unreleased]

### Fixed

- Added missing Ziranma double-pinyin support for subscribed vocabularies. The next deployment generates and mounts Ziranma tables for existing subscriptions.

## [0.1.9] - 2026-10-13

> Stable release · Developer ID signed · notarized by Apple
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ AIME 每个版本面向用户的变动都记在这里,官网「更新日志」

## [Unreleased]

### 修复

- 补上自然码双拼的订阅词库支持,已有订阅在下次部署时自动生成自然码词表并接入候选。

## [0.1.9] - 2026-10-13

> 正式版 · Developer ID 签名 · 已通过 Apple 公证
Expand Down
23 changes: 19 additions & 4 deletions Packages/AIMEKit/Sources/AIMECore/DoublePinyin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,25 @@ public import Foundation
public enum DoublePinyin: String, Sendable, CaseIterable {
/// 小鹤双拼 (double_pinyin_flypy)
case flypy
/// 自然码双拼 (double_pinyin)
case ziranma

/// Schema ids that use this layout.
public var schemaIDs: [String] {
switch self { case .flypy: ["double_pinyin_flypy"] }
switch self {
case .flypy: ["double_pinyin_flypy"]
case .ziranma: ["double_pinyin"]
}
}

public static func layout(forSchema id: String) -> DoublePinyin? {
allCases.first { $0.schemaIDs.contains(id) }
}

private var initials: [String: String] {
switch self { case .flypy: ["zh": "v", "ch": "i", "sh": "u"] }
switch self {
case .flypy, .ziranma: ["zh": "v", "ch": "i", "sh": "u"]
}
}

private var finals: [String: String] {
Expand All @@ -27,6 +34,13 @@ public enum DoublePinyin: String, Sendable, CaseIterable {
"ing": "k", "uai": "k", "iang": "l", "uang": "l", "ou": "z", "ia": "x", "ua": "x", "ao": "c",
"ui": "v", "v": "v", "in": "b", "iao": "n", "ian": "m",
]
case .ziranma: [
"iu": "q", "ia": "w", "ua": "w", "uan": "r", "van": "r", "ue": "t", "ve": "t",
"ing": "y", "uai": "y", "uo": "o", "un": "p", "vn": "p", "ong": "s", "iong": "s",
"iang": "d", "uang": "d", "en": "f", "eng": "g", "ang": "h", "ian": "m", "an": "j",
"iao": "c", "ao": "k", "ai": "l", "ei": "z", "ie": "x", "ui": "v", "ou": "b",
"in": "n", "v": "v"
]
}
}

Expand All @@ -49,8 +63,9 @@ public enum DoublePinyin: String, Sendable, CaseIterable {
}
let initial = String(syllable.first!)
let rest = String(syllable.dropFirst())
// j/q/x/y + u is ü.
let normalized = ("jqxy".contains(initial) && rest.hasPrefix("u")) ? "v" + rest.dropFirst() : rest
// Flypy uses v for j/q/x/y + ü; Ziranma retains the written u (ju, qu, xu, yu).
let normalized = (self == .flypy && "jqxy".contains(initial) && rest.hasPrefix("u"))
? "v" + rest.dropFirst() : rest
if normalized.count == 1 { return initial + normalized }
guard let final = finals[normalized] ?? finals[rest] else { return nil }
return initial + final
Expand Down
25 changes: 25 additions & 0 deletions Packages/AIMEKit/Tests/AIMECoreTests/DoublePinyinTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,29 @@ struct DoublePinyinTests {
#expect(flypy.code(forPinyin: "not a syllable xq") == nil)
#expect(DoublePinyin.layout(forSchema: "double_pinyin_flypy") == .flypy)
}

@Test func convertsSyllablesToZiranma() {
let ziranma = DoublePinyin.ziranma
let cases: [String: String] = [
"ni": "ni", "hao": "hk", "jie": "jx", "chuang": "id", "xue": "xt",
"lv": "lv", "lve": "lt", "ju": "ju", "qu": "qu", "xu": "xu", "yu": "yu",
"a": "aa", "e": "ee", "ai": "ai", "an": "an", "ang": "ah", "eng": "eg", "er": "er", "ou": "ou",
"xiong": "xs", "guang": "gd", "zhuang": "vd", "sheng": "ug", "yuan": "yr", "yun": "yp",
"bin": "bn", "liu": "lq", "shuang": "ud", "jiao": "jc", "gou": "gb", "ying": "yy"
]
for (pinyin, code) in cases {
#expect(ziranma.code(forSyllable: pinyin) == code, "\(pinyin)")
}
#expect(ziranma.code(forPinyin: "xiang liang shu ju ku") == "xdlduujuku")
#expect(ziranma.code(forPinyin: "ju shen zhi neng") == "juufving")
#expect(flypy.code(forPinyin: "ju shen zhi neng") == "jvufving")
}

@Test func recognizesOnlyKnownDoublePinyinSchemas() {
#expect(DoublePinyin.layout(forSchema: "double_pinyin") == .ziranma)
#expect(DoublePinyin.layout(forSchema: "double_pinyin_flypy") == .flypy)
#expect(DoublePinyin.layout(forSchema: "double_pinyin_custom") == nil)
#expect(DoublePinyin.layout(forSchema: "double_pinyin_mspy") == nil)
#expect(DoublePinyin.layout(forSchema: "rime_ice") == nil)
}
}
36 changes: 33 additions & 3 deletions Packages/AIMEKit/Tests/AIMECoreTests/SubscriptionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,20 +46,50 @@ struct SubscriptionTests {

@Test func rebuildWritesLayoutTablesAndMountsSchemas() async throws {
let source = root.appendingPathComponent("feed.txt")
try "氛围编程\n".write(to: source, atomically: true, encoding: .utf8)
try "氛围编程\n向量数据库\txiang liang shu ju ku\t80\n".write(to: source, atomically: true, encoding: .utf8)
let manager = SubscriptionManager(paths: paths)
try await manager.add(url: source.absoluteString, name: nil)
let count = try manager.rebuild(schemas: ["rime_ice", "double_pinyin_flypy"])
#expect(count == 3)
let count = try manager.rebuild(schemas: ["rime_ice", "double_pinyin_flypy", "double_pinyin", "double_pinyin_custom"])
#expect(count == 4)
let full = try String(contentsOf: paths.userDataDir.appendingPathComponent("aime_online.txt"), encoding: .utf8)
let flypy = try String(contentsOf: paths.userDataDir.appendingPathComponent("aime_online_flypy.txt"), encoding: .utf8)
let ziranma = try String(contentsOf: paths.userDataDir.appendingPathComponent("aime_online_ziranma.txt"), encoding: .utf8)
#expect(full.contains("智能体\tzhinengti") && full.contains("DeepSeek\tdeepseek"))
#expect(flypy.contains("智能体\tvingti") && flypy.contains("氛围编程\tffwwbmig"))
#expect(ziranma.contains("向量数据库\txdlduujuku\t80") && ziranma.contains("DeepSeek\tdeepseek"))
let layers = ConfigLayers(paths: paths)
#expect(layers.generatedValue(.schema("double_pinyin_flypy"), keypath: "aime_online")?["user_dict"] == "aime_online_flypy")
#expect(layers.generatedValue(.schema("double_pinyin"), keypath: "aime_online")?["user_dict"] == "aime_online_ziranma")
#expect(layers.generatedValue(.schema("double_pinyin"), keypath: "engine/translators/+") == ["table_translator@aime_online"])
#expect(layers.generatedValue(.schema("double_pinyin_custom"), keypath: "aime_online") == nil)
#expect(layers.generatedValue(.schema("rime_ice"), keypath: "engine/translators/+") == ["table_translator@aime_online"])
}

@Test func existingSubscriptionCacheAddsMissingZiranmaTableWithoutDownloading() async throws {
try "schema_list:\n - schema: double_pinyin\n".write(
to: paths.sharedDataDir!.appendingPathComponent("default.yaml"), atomically: true, encoding: .utf8)
let source = root.appendingPathComponent("feed.txt")
try "具身智能\tju shen zhi neng\t80\n".write(to: source, atomically: true, encoding: .utf8)
let manager = SubscriptionManager(paths: paths)
let added = try await manager.add(url: source.absoluteString, name: nil)
let cached = try Data(contentsOf: manager.cacheURL(added.id))
#expect(try manager.ensureTables())
#expect(try manager.ensureTables() == false)

// An older installation has the cache and signature, but no Ziranma table or mount.
let table = paths.userDataDir.appendingPathComponent("aime_online_ziranma.txt")
try FileManager.default.removeItem(at: table)
try FileManager.default.removeItem(at: paths.aimeDir.appendingPathComponent("generated/double_pinyin.yaml"))
try FileManager.default.removeItem(at: source)

#expect(try manager.ensureTables())
#expect(try String(contentsOf: table, encoding: .utf8).contains("具身智能\tjuufving\t80"))
#expect(ConfigLayers(paths: paths).generatedValue(.schema("double_pinyin"), keypath: "aime_online")?["user_dict"]
== "aime_online_ziranma")
#expect(try Data(contentsOf: manager.cacheURL(added.id)) == cached)
#expect(try manager.ensureTables() == false)
}

@Test func ensureTablesDedupesAndRebuildsOnlyWhenInputsChange() async throws {
try "schema_list:\n - schema: rime_ice\n".write(
to: paths.sharedDataDir!.appendingPathComponent("default.yaml"), atomically: true, encoding: .utf8)
Expand Down