Skip to content
Merged
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,23 @@ it needs to read like release notes rather than a commit log.
than assumed, and when a file cannot be identified the reader is shown a
preview of each candidate instead of an error. Every imported file is
copied in behind one security-scoped boundary, on both platforms.
- **Your own dictionary.** Import an ABBYY DSL dictionary — 大БКРС, 3.4 million
cards — from inside the app, with progress, a Stop that keeps what it has
already imported, and a resume that carries on from there rather than
starting again. Interrupting it, including by force-quitting the app, is
safe.
- Definitions with their structure intact: numbered senses under Roman-numeral
divisions, parts of speech, registers and usage examples. The predecessor
flattened every card into a single unseparated string — one sense per entry
across all 3.4 million of them — where 爱 has 32 senses and 一 and 打 have 57
each.
- Several dictionaries at once, grouped by reading rather than by dictionary,
so 了's `le` and `liǎo` each show what every dictionary says about them. The
answers are never merged and always say which dictionary they came from.
- A dictionary can supply definitions, readings, or the word list your text is
split into, each separately — because a dictionary you do not read can still
be the one that knows how a character is pronounced. A dictionary you have
not ordered sorts by its language against yours, so a Russian-speaking
reader sees a Russian dictionary first with no configuration.

[Unreleased]: https://github.com/OpenIPC/HanReader/commits/main
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ framework imports. Details in [`Docs/ARCHITECTURE.md`](Docs/ARCHITECTURE.md).
- [x] **M3** — dictionary pipeline and zero-setup first launch
- [x] **M4** — word segmentation and the line-breaking core
- [x] **M5** — **the macOS reader: first usable version**
- [ ] **M6** — BKRS DSL import
- [x] **M6** — **BKRS DSL import**
- [ ] **M7** — audio playback
- [ ] **M8** — iOS and iPadOS
- [ ] **M9** — accessibility and Russian localization
Expand Down
11 changes: 10 additions & 1 deletion Sources/HanReaderCore/Format/DSLFileSet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,17 @@ public struct DSLFileSet: Hashable, Sendable {
continue
}
guard visited.insert(target).inserted else { continue }
// A volume that is there but cannot be read is reported the same
// way as one that is absent, rather than failing the whole
// discovery. Under a sandbox the reader's choice of one file need
// not grant access to its siblings, and a dictionary importing
// two volumes of three with a diagnostic beats one importing
// nothing with an error.
guard let nested = try? DSLHeader.read(from: DSLFileBytes(url: target)) else {
diagnostics.append(DSLDiagnostic(kind: .missingInclude(name), text: name))
continue
}
files.append(target)
let nested = try DSLHeader.read(from: DSLFileBytes(url: target))
queue += nested.includes.map { (parent: target, name: $0) }
}
return Self(
Expand Down
29 changes: 29 additions & 0 deletions Sources/HanReaderDictionaryImport/DSLImporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,35 @@ public struct DSLImportSummary: Hashable, Sendable {
/// most ten times a second, and always once at the end so a bar cannot stop
/// short of full.
public struct DSLImporter: Sendable {
/// What the container compiled from this set will be called.
///
/// Needed before the import starts, not after, because the destination
/// has to be the same file on a second run or an interrupted import
/// cannot resume — it would write a new file beside the half-finished
/// one. Derived from the same metadata the finished container records,
/// so the two cannot disagree.
public static func slug(for fileSet: DSLFileSet) -> String {
metadata(
for: fileSet,
entryCount: 0,
fileName: fileSet.files.first?.lastPathComponent ?? "dictionary",
).slug
}

/// What the compiled dictionary will be called.
///
/// The same name the finished container records, so the progress banner
/// and the installed list cannot disagree — the raw header says
/// `大БКРС - 250920 ( 1 / 3 )`, and the volume count belongs to the file
/// rather than to the dictionary the three of them compile into.
public static func displayName(for fileSet: DSLFileSet) -> String {
metadata(
for: fileSet,
entryCount: 0,
fileName: fileSet.files.first?.lastPathComponent ?? "dictionary",
).displayName
}

/// Bumped when a parser change alters what gets written.
///
/// A container built by an older parser is rebuilt rather than migrated,
Expand Down
109 changes: 104 additions & 5 deletions Sources/HanReaderPersistence/DictionaryCatalogue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ public struct DictionaryPreference: Hashable, Sendable {
}
}

extension DictionaryPreference {
/// A copy with one permission changed.
public func with(glosses: Bool? = nil, readings: Bool? = nil, lexicon: Bool? = nil) -> Self {
Self(
slug: slug,
providesGlosses: glosses ?? providesGlosses,
providesReadings: readings ?? providesReadings,
providesLexicon: lexicon ?? providesLexicon,
sortOrder: sortOrder,
)
}
}

/// An installed dictionary, as the reader's list shows it.
public struct InstalledDictionary: Hashable, Sendable {
/// From the container's own `meta`, never from a copy.
Expand Down Expand Up @@ -80,15 +93,47 @@ public struct UnreadableDictionary: Hashable, Sendable {
public struct DictionaryCatalogue: Sendable {
public let installed: [InstalledDictionary]
public let unreadable: [UnreadableDictionary]
/// Imports that stopped part way, which the file says of itself by still
/// carrying its job row. Not failures: importing the same source again
/// carries on from where it stopped.
public let unfinished: [URL]

public init(installed: [InstalledDictionary], unreadable: [UnreadableDictionary] = []) {
public init(
installed: [InstalledDictionary],
unreadable: [UnreadableDictionary] = [],
unfinished: [URL] = [],
) {
self.installed = installed
self.unreadable = unreadable
self.unfinished = unfinished
}

/// The extension a compiled dictionary has.
public static let fileExtension = "hanreaderdict"

/// What an import writes to until it finishes.
///
/// A separate name, not the final one, and that settles three things at
/// once. A reader re-importing a dictionary they already have does not
/// lose it the moment the new import opens its destination and clears it.
/// A container that stopped part way is recognised as unfinished by its
/// name, so it needs no metadata, no job row and — crucially — no
/// read-only open, which a half-written database in write-ahead mode
/// cannot survive once its sidecar files are gone. And the name is still
/// derived from the dictionary's own slug, so importing the same source
/// again lands on the same file and carries on.
public static let partialExtension = "hanreaderdict-import"

/// Where an import of this dictionary should write.
public static func partialFile(forSlug slug: String, in directory: URL) -> URL {
directory.appendingPathComponent("\(slug).\(partialExtension)")
}

/// Where it belongs once it is finished.
public static func finishedFile(forSlug slug: String, in directory: URL) -> URL {
directory.appendingPathComponent("\(slug).\(fileExtension)")
}

public var isEmpty: Bool {
installed.isEmpty
}
Expand Down Expand Up @@ -124,6 +169,24 @@ extension DictionaryCatalogue {
return (DictionarySet(members: members), failures)
}

/// The toneless syllables the installed dictionaries use.
///
/// Needed when importing a new one: a DSL dictionary writes its readings
/// as diacritics, and splitting `tǔ'ěrqísītǎn` into syllables is what
/// makes it comparable with CC-CEDICT's numeric form. Without them a
/// reading still displays exactly as written and only the
/// cross-dictionary merge key is lost, so this degrades rather than
/// fails.
public func syllableBases() -> Set<String> {
var bases: Set<String> = []
for entry in installed where entry.providesReadings {
bases.formUnion(
(try? DictionaryContainer(contentsOf: entry.file).syllableBases()) ?? [],
)
}
return bases
}

/// The segmentation word list, from the dictionaries allowed to supply
/// one.
///
Expand Down Expand Up @@ -177,19 +240,56 @@ extension AppDatabase {
// the file system happens to answer in.
.sorted { $0.lastPathComponent < $1.lastPathComponent }
.map { ($0, false) }
// Recognised by name rather than by opening it. A container an
// import did not finish has no metadata to read, and may not open
// read-only at all.
let partial = contents
.filter { $0.pathExtension == DictionaryCatalogue.partialExtension }
.sorted { $0.lastPathComponent < $1.lastPathComponent }

let preferences = try await library.dictionaryPreferences()
var catalogue = Accumulator()
for (url, isBuiltIn) in files {
catalogue.add(
url,
isBuiltIn: isBuiltIn,
preferences: preferences,
preferredLanguages: preferredLanguages,
)
}
return catalogue.result(unfinished: partial)
}

/// Builds a catalogue one file at a time.
///
/// Three outcomes per file, and keeping them apart is the point: a
/// dictionary, an import that stopped part way, and a file that is not
/// either. Only the last is a failure.
private struct Accumulator {
var installed: [InstalledDictionary] = []
var unreadable: [UnreadableDictionary] = []

for (url, isBuiltIn) in files {
func result(unfinished: [URL]) -> DictionaryCatalogue {
DictionaryCatalogue(
installed: installed,
unreadable: unreadable,
unfinished: unfinished,
)
}

mutating func add(
_ url: URL,
isBuiltIn: Bool,
preferences: [String: DictionaryPreference],
preferredLanguages: [String],
) {
do {
guard let metadata = try DictionaryContainer(contentsOf: url).metadata() else {
unreadable.append(UnreadableDictionary(
file: url,
reason: "the file has no dictionary metadata",
))
continue
return
}
let stored = preferences[metadata.slug]
installed.append(InstalledDictionary(
Expand All @@ -204,7 +304,7 @@ extension AppDatabase {
// word-level, and it is the lexicon the reader has until
// they say otherwise.
providesLexicon: stored?.providesLexicon ?? isBuiltIn,
sortOrder: stored?.sortOrder ?? defaultOrder(
sortOrder: stored?.sortOrder ?? AppDatabase.defaultOrder(
for: metadata,
preferredLanguages: preferredLanguages,
),
Expand All @@ -216,7 +316,6 @@ extension AppDatabase {
))
}
}
return DictionaryCatalogue(installed: installed, unreadable: unreadable)
}

/// Where a dictionary the reader has not ordered should sit.
Expand Down
53 changes: 53 additions & 0 deletions Sources/HanReaderPersistence/LibraryRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,59 @@ extension LibraryRepository {
}
}

/// Which of a dictionary's three permissions a change is about.
public enum DictionaryPermission: Sendable {
case glosses
case readings
case lexicon

fileprivate var column: String {
switch self {
case .glosses: "providesGlosses"
case .readings: "providesReadings"
case .lexicon: "providesLexicon"
}
}
}

/// Changes one permission, leaving the others as they are.
///
/// One column, not the whole row. Writing the whole row means writing the
/// values the caller read before the change — so a reader who flicks two
/// switches in quick succession has the second write restore what the
/// first one changed, and one of their switches silently flicks back.
///
/// `current` is only used if there is no row yet, where every column has
/// to be given a value and the right ones are the defaults the catalogue
/// was already showing — including a sort order that came from the
/// reader's own languages rather than from zero.
public func setDictionaryPermission(
_ permission: DictionaryPermission,
to value: Bool,
matching current: DictionaryPreference,
) async throws {
let inserted = switch permission {
case .glosses: current.with(glosses: value)
case .readings: current.with(readings: value)
case .lexicon: current.with(lexicon: value)
}
try await dbQueue.write { db in
try db.execute(sql: """
INSERT INTO dictionaryPreference
(slug, providesGlosses, providesReadings, providesLexicon, sortOrder)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET \(permission.column) = ?
""", arguments: [
inserted.slug,
inserted.providesGlosses,
inserted.providesReadings,
inserted.providesLexicon,
inserted.sortOrder,
value,
])
}
}

/// Records what the reader decided about one dictionary.
public func save(_ preference: DictionaryPreference) async throws {
try await dbQueue.write { db in
Expand Down
Loading
Loading